diff --git a/.claude/settings.json b/.claude/settings.json index 5cb01328..e7d7a3fd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,7 @@ "permissions": { "allow": [ "Bash(git *)", + "Bash(gh pr *)", "Bash(cargo *)", "Bash(gh *)", "Bash(ls *)", diff --git a/AGENTS.md b/AGENTS.md index afa59e6f..7b28fd61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,17 +32,33 @@ Prereqs: Java JDK and Apache Ant are required; Maven is used for dependency reso ## Branching Strategy - **Use `rewrite-in-rust` as the main branch.** All new development and PRs should target this branch. - **NEVER commit or merge directly into `master`.** The `master` branch exists for legacy reasons only and should not be modified. +- **PRs should ALWAYS use `rewrite-in-rust` as the base branch**, not `master`, unless explicitly specified otherwise. - Create feature branches from `rewrite-in-rust` and merge back into it. +## Active Development +- **All active development happens in Rust** under the `crates/` directory. +- **The Java codebase is legacy and read-only** — it exists only as a reference for porting functionality to Rust. Never modify Java code. + ## Commit & Pull Request Guidelines - Recent history favors short, sentence-style commit messages (e.g., "Use Ctrl key on Windows."). Keep messages concise and specific. - PRs should describe the user-visible change, list test commands run, and include screenshots or recordings for UI updates. - Link relevant issues or tickets when applicable. +## Code Quality +- **Fix all compiler warnings before handing off code.** Run `cargo check --workspace --exclude nodebox-python` and ensure zero warnings before completing a task. Deprecation warnings, unused imports, dead code warnings, and any other diagnostics must be resolved — not suppressed — unless there is a documented reason (see "Rust Dead Code Warnings" for approved suppression patterns). + ## Notes for Contributors - Versioning lives in `src/main/resources/version.properties`; update it when preparing a release build. - **NEVER modify the Java code** (`src/main/java`). The Java codebase is legacy and read-only; use it only as a reference. All new development happens in the Rust crates under `crates/`. +## Async Node Implementation + +For nodes that perform I/O operations or expensive computations, see **[docs/async_nodes.md](docs/async_nodes.md)** for: +- Cancellation token usage +- Async I/O patterns with smol +- Best practices for responsive cancellation +- Testing async-aware nodes + ## Node Definitions and Implementations Node definitions and their implementations are split across multiple locations: @@ -60,9 +76,9 @@ Node definitions and their implementations are split across multiple locations: - **Python** (`pyvector/*`): `src/main/python/` modules ### Rust Implementations -- **Node operations**: `crates/nodebox-ops/src/` (generators.rs, filters.rs, etc.) -- **Node registration**: `crates/nodebox-gui/src/node_library.rs` and `node_selection_dialog.rs` -- **Node evaluation**: `crates/nodebox-gui/src/eval.rs` +- **Node operations**: `crates/nodebox-core/src/ops/` (generators.rs, filters.rs, etc.) +- **Node registration**: `crates/nodebox-desktop/src/node_library.rs` and `node_selection_dialog.rs` +- **Node evaluation**: `crates/nodebox-desktop/src/eval.rs` ## Porting Nodes from Java to Rust @@ -98,7 +114,7 @@ The NodeBox GUI follows a **Linear-inspired design philosophy**: ### Quick Reference -All tokens are in `crates/nodebox-gui/src/theme.rs`. Key patterns: +All tokens are in `crates/nodebox-desktop/src/theme.rs`. Key patterns: ```rust use crate::theme::{ @@ -240,6 +256,35 @@ When styling egui widgets (DragValue, checkbox, etc.) to match the style guide: 4. Override ALL states: `inactive`, `hovered`, `active`, `noninteractive` 5. Save and restore both `visuals` and `spacing` to avoid affecting other widgets +## NodeLibrary Arc Pattern + +The `NodeLibrary` is wrapped in `Arc` for cheap cloning and copy-on-write semantics. This enables: +- **Render dispatch**: The render worker receives a cheap `Arc::clone` of the library without deep-copying the entire node graph. +- **Undo/redo history**: `History` stores `Vec>` snapshots that share unchanged data. + +### Reading (no mutation) +Pass `&Arc` or clone the Arc for background threads: +```rust +render_worker.submit(Arc::clone(&state.library)); +``` + +### Writing (mutation) +Use `Arc::make_mut` to get a mutable reference. This clones the inner data only if other Arcs still reference it (copy-on-write): +```rust +Arc::make_mut(&mut state.library).root.children.push(new_node); +``` + +For multiple mutations in a block, bind `Arc::make_mut` once: +```rust +let lib = Arc::make_mut(&mut state.library); +lib.root.children.retain(|n| &n.name != name); +lib.root.connections.retain(|c| &c.output_node != name); +``` + +### Function signatures +- Read-only: `fn show(&self, library: &Arc)` +- Mutating: `fn show(&mut self, library: &mut Arc)` + ## Build Commands ### Excluding problematic crates @@ -249,9 +294,8 @@ cargo build --workspace --exclude nodebox-python cargo test --workspace --exclude nodebox-python ``` -### Running specific crates +### Running the application ```bash -cargo run -p nodebox-gui # Run the GUI -cargo run -p nodebox-cli # Run the CLI +cargo run # Run the desktop GUI application cargo test -p nodebox-core # Test specific crate ``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index b09f3c5e..bfeb1b7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -641,6 +641,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -1086,19 +1092,67 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1109,7 +1163,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1590,6 +1644,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "float-ord" version = "0.3.2" @@ -1648,6 +1708,29 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e32eac81c1135c1df01d4e6d4233c47ba11f6a6d07f33e0bba09d18797077770" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.21.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2373,6 +2456,12 @@ dependencies = [ "quick-error 2.0.1", ] +[[package]] +name = "imagesize" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" + [[package]] name = "imgref" version = "1.12.0" @@ -2424,6 +2513,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jiff" version = "0.2.18" @@ -2529,6 +2624,17 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.0" @@ -2851,17 +2957,7 @@ name = "nodebox" version = "0.1.0" dependencies = [ "eframe", - "nodebox-gui", -] - -[[package]] -name = "nodebox-cli" -version = "0.1.0" -dependencies = [ - "nodebox-core", - "nodebox-ndbx", - "nodebox-ops", - "nodebox-svg", + "nodebox-desktop", ] [[package]] @@ -2869,74 +2965,56 @@ name = "nodebox-core" version = "0.1.0" dependencies = [ "approx", + "csv", "font-kit", + "log", "pathfinder_geometry", "proptest", + "quick-xml 0.31.0", + "rayon", + "tempfile", + "thiserror 1.0.69", + "usvg", "uuid", ] [[package]] -name = "nodebox-gui" +name = "nodebox-desktop" version = "0.1.0" dependencies = [ + "arboard", + "directories", "eframe", "egui", "egui-wgpu", "egui_extras", "egui_kittest", "env_logger", + "font-kit", "image", "log", "muda", "nodebox-core", - "nodebox-ndbx", - "nodebox-ops", - "nodebox-svg", "pollster", "rfd", + "serde", + "serde_json", + "smol", "tempfile", "tiny-skia", + "ureq", "vello", ] -[[package]] -name = "nodebox-ndbx" -version = "0.1.0" -dependencies = [ - "nodebox-core", - "proptest", - "quick-xml 0.31.0", - "thiserror 1.0.69", -] - -[[package]] -name = "nodebox-ops" -version = "0.1.0" -dependencies = [ - "approx", - "nodebox-core", - "proptest", - "rayon", -] - [[package]] name = "nodebox-python" version = "0.1.0" dependencies = [ "nodebox-core", - "nodebox-ops", "pyo3", "tempfile", ] -[[package]] -name = "nodebox-svg" -version = "0.1.0" -dependencies = [ - "approx", - "nodebox-core", -] - [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3365,7 +3443,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -3460,7 +3538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2b6aadb221872732e87d465213e9be5af2849b0e8cc5300a8ba98fffa2e00a" dependencies = [ "color", - "kurbo", + "kurbo 0.13.0", "linebender_resource_handle", "smallvec", ] @@ -3515,6 +3593,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.10" @@ -4048,6 +4132,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4124,6 +4219,26 @@ version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4171,6 +4286,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4189,6 +4339,28 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4259,6 +4431,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4310,6 +4495,15 @@ dependencies = [ "quote", ] +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -4410,6 +4604,23 @@ dependencies = [ "wayland-backend", ] +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -4445,6 +4656,15 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svg_fmt" @@ -4452,6 +4672,16 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "syn" version = "1.0.109" @@ -4615,6 +4845,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml" version = "0.8.2" @@ -4722,6 +4967,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4760,18 +5011,54 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.1.14" @@ -4784,6 +5071,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4803,6 +5112,33 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5159,6 +5495,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5528,6 +5882,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5579,6 +5942,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5627,6 +6005,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5645,6 +6029,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5663,6 +6053,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5693,6 +6089,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5711,6 +6113,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5729,6 +6137,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5747,6 +6161,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5923,6 +6343,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "y4m" version = "0.8.0" @@ -6101,6 +6527,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" @@ -6134,6 +6566,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index fe51d41e..48b85ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ name = "nodebox" version.workspace = true edition.workspace = true - default-run = "NodeBox" [[bin]] @@ -10,32 +9,24 @@ name = "NodeBox" path = "src/main.rs" [dependencies] -nodebox-gui = { path = "crates/nodebox-gui" } +nodebox-desktop = { path = "crates/nodebox-desktop" } eframe = "0.33" [workspace] resolver = "2" members = [ + ".", "crates/nodebox-core", - "crates/nodebox-ndbx", - "crates/nodebox-ops", - "crates/nodebox-svg", - "crates/nodebox-cli", - "crates/nodebox-gui", + "crates/nodebox-desktop", "crates/nodebox-python", ] -# Default members exclude nodebox-python since it requires Python development libraries. -# To build nodebox-python, install Python dev headers and run: -# cargo build -p nodebox-python +# Default members exclude nodebox-python (requires Python development libraries). +# To build it: cargo build -p nodebox-python default-members = [ ".", "crates/nodebox-core", - "crates/nodebox-ndbx", - "crates/nodebox-ops", - "crates/nodebox-svg", - "crates/nodebox-cli", - "crates/nodebox-gui", + "crates/nodebox-desktop", ] [workspace.package] diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index de2e9309..0014c5b4 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -2,7 +2,7 @@ This document defines the visual language and design principles for the NodeBox GUI. All UI development should follow these guidelines to ensure a consistent, professional, and modern interface. -**Reference Implementation:** `crates/nodebox-gui/src/theme.rs` +**Reference Implementation:** `crates/nodebox-desktop/src/theme.rs` --- @@ -434,6 +434,146 @@ use crate::theme::{ --- +## egui Styling System + +NodeBox uses egui for its GUI. Understanding how to properly configure egui's styling is essential for maintaining visual consistency. + +### Global Style Configuration + +All global styles are configured in `theme.rs` in the `configure_style()` function. This function is called once at startup. + +```rust +pub fn configure_style(ctx: &egui::Context) { + let mut style = Style::default(); + let mut visuals = Visuals::dark(); + + // ... configure style and visuals ... + + style.visuals = visuals; + ctx.set_style(style); +} +``` + +### Widget Visuals: Set ALL Properties + +**Critical:** egui widgets have multiple background properties. If you only set `bg_fill`, egui will use its default brownish-gray for other properties. Always set ALL of these: + +```rust +// For each widget state (noninteractive, inactive, hovered, active, open): +visuals.widgets.inactive.bg_fill = SLATE_700; // Primary background +visuals.widgets.inactive.weak_bg_fill = SLATE_700; // IMPORTANT: Button frames use this! +visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, SLATE_200); // Text/icon color +visuals.widgets.inactive.bg_stroke = Stroke::NONE; // Border (usually none) +visuals.widgets.inactive.corner_radius = CornerRadius::ZERO; +``` + +The `weak_bg_fill` property is particularly important - it's used for: +- Button backgrounds +- ComboBox button backgrounds +- Frame backgrounds for "weak" (unfocused) widgets + +If you see brownish-gray colors appearing, you likely forgot to set `weak_bg_fill`. + +### Widget States + +egui has five widget states, each needs full configuration: + +| State | When Used | +|-------|-----------| +| `noninteractive` | Labels, static text, disabled widgets | +| `inactive` | Buttons/widgets not being interacted with | +| `hovered` | Mouse hovering over widget | +| `active` | Widget being clicked/pressed | +| `open` | ComboBox/menu when popup is open | + +### Menu and Popup Styling + +For menus and popups (like ComboBox dropdowns): + +```rust +// Sharp corners on menu popups +visuals.menu_corner_radius = CornerRadius::ZERO; + +// No shadow on popups +visuals.popup_shadow = egui::Shadow::NONE; + +// Tight menu margins +style.spacing.menu_margin = egui::Margin::same(2); +``` + +### Selection Styling + +For selected items in lists and menus: + +```rust +visuals.selection.bg_fill = SELECTION_BG; // Background color (violet) +visuals.selection.stroke = Stroke::new(1.0, TEXT_STRONG); // Text visibility +``` + +**Note:** `selection.stroke` helps ensure text remains visible on the selection background. + +### Local Style Overrides + +For widget-specific styling, modify `ui.style_mut()` before rendering: + +```rust +// Example: Smaller font for a specific widget +let style = ui.style_mut(); +style.override_font_id = Some(egui::FontId::proportional(FONT_SIZE_SMALL)); + +// Then render your widget +egui::ComboBox::from_id_salt(id) + .selected_text(label) + .show_ui(ui, |ui| { ... }); +``` + +### Common Styling Properties + +| Property | Location | Purpose | +|----------|----------|---------| +| `style.spacing.button_padding` | Spacing | Internal button padding | +| `style.spacing.menu_margin` | Spacing | Menu interior margin | +| `style.spacing.item_spacing` | Spacing | Gap between items | +| `visuals.window_corner_radius` | Visuals | Window/dialog corners | +| `visuals.menu_corner_radius` | Visuals | Menu popup corners | +| `visuals.popup_shadow` | Visuals | Shadow on popups | +| `visuals.window_shadow` | Visuals | Shadow on windows | +| `visuals.selection.bg_fill` | Visuals | Selection highlight color | +| `visuals.widgets.*.bg_fill` | Visuals | Widget background | +| `visuals.widgets.*.weak_bg_fill` | Visuals | Widget frame/button background | +| `visuals.widgets.*.fg_stroke` | Visuals | Widget text/icon color | + +### Debugging Style Issues + +If a widget has wrong colors: + +1. **Brownish-gray background?** → Set `weak_bg_fill` for all widget states +2. **Wrong text color?** → Check `fg_stroke` for the relevant state +3. **Rounded corners appearing?** → Set `corner_radius = CornerRadius::ZERO` +4. **Selection not visible?** → Check `selection.bg_fill` and `selection.stroke` +5. **Menu has shadows?** → Set `popup_shadow = Shadow::NONE` + +### Example: Full Widget State Configuration + +```rust +// Inactive state (not hovered, not clicked) +visuals.widgets.inactive.bg_fill = SLATE_700; +visuals.widgets.inactive.weak_bg_fill = SLATE_700; +visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, SLATE_200); +visuals.widgets.inactive.corner_radius = CornerRadius::ZERO; +visuals.widgets.inactive.bg_stroke = Stroke::NONE; + +// Hovered state +visuals.widgets.hovered.bg_fill = SLATE_600; +visuals.widgets.hovered.weak_bg_fill = SLATE_600; +visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, SLATE_100); +visuals.widgets.hovered.corner_radius = CornerRadius::ZERO; +visuals.widgets.hovered.expansion = 0.0; // No size change on hover +visuals.widgets.hovered.bg_stroke = Stroke::NONE; +``` + +--- + ## Evolution This design system is living documentation. When adding new patterns: diff --git a/crates/nodebox-cli/Cargo.toml b/crates/nodebox-cli/Cargo.toml deleted file mode 100644 index 1c3d3528..00000000 --- a/crates/nodebox-cli/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "nodebox-cli" -description = "Command-line interface for NodeBox" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true - -[[bin]] -name = "nodebox-cli" -path = "src/main.rs" - -[dependencies] -nodebox-core = { path = "../nodebox-core" } -nodebox-ops = { path = "../nodebox-ops" } -nodebox-ndbx = { path = "../nodebox-ndbx" } -nodebox-svg = { path = "../nodebox-svg" } diff --git a/crates/nodebox-cli/src/main.rs b/crates/nodebox-cli/src/main.rs deleted file mode 100644 index cf741cc8..00000000 --- a/crates/nodebox-cli/src/main.rs +++ /dev/null @@ -1,407 +0,0 @@ -//! NodeBox CLI - Command-line interface for NodeBox Rust -//! -//! A simple tool to experiment with NodeBox geometry operations. - -use nodebox_core::geometry::{Path, Color, Point, font}; -use nodebox_ops::{rect, star, polygon}; -use nodebox_svg::render_to_svg; -use std::io::{self, Write}; - -fn main() { - let args: Vec = std::env::args().collect(); - - if args.len() > 1 { - // Command mode - match args[1].as_str() { - "demo" => run_demo(&args[2..]), - "text" => run_text_to_svg(&args[2..]), - "help" | "--help" | "-h" => print_help(), - "version" | "--version" | "-V" => print_version(), - _ => { - eprintln!("Unknown command: {}", args[1]); - eprintln!("Run 'nodebox help' for usage."); - std::process::exit(1); - } - } - } else { - // Interactive mode - run_interactive(); - } -} - -fn print_help() { - println!(r#" -NodeBox CLI - Generative design toolkit - -USAGE: - nodebox [COMMAND] [OPTIONS] - -COMMANDS: - demo Generate a demo SVG (shapes, spiral, text, bezier) - text Convert text to SVG path - help Show this help message - version Show version info - -EXAMPLES: - nodebox demo shapes > shapes.svg - nodebox text "Hello World" > hello.svg - nodebox # Interactive mode - -Run without arguments for interactive mode. -"#); -} - -fn print_version() { - println!("NodeBox CLI v{}", env!("CARGO_PKG_VERSION")); - println!("Built with Rust"); -} - -fn run_demo(args: &[String]) { - let demo_name = args.get(0).map(|s| s.as_str()).unwrap_or("shapes"); - - let svg = match demo_name { - "shapes" => demo_shapes(), - "spiral" => demo_spiral(), - "text" => demo_text(), - "bezier" => demo_bezier(), - "all" => { - // Output all demos to files - std::fs::create_dir_all("output").ok(); - std::fs::write("output/shapes.svg", demo_shapes()).ok(); - std::fs::write("output/spiral.svg", demo_spiral()).ok(); - std::fs::write("output/text.svg", demo_text()).ok(); - std::fs::write("output/bezier.svg", demo_bezier()).ok(); - eprintln!("Generated: output/shapes.svg, spiral.svg, text.svg, bezier.svg"); - return; - } - _ => { - eprintln!("Unknown demo: {}", demo_name); - eprintln!("Available: shapes, spiral, text, bezier, all"); - std::process::exit(1); - } - }; - - println!("{}", svg); -} - -fn run_text_to_svg(args: &[String]) { - let text = args.join(" "); - if text.is_empty() { - eprintln!("Usage: nodebox text "); - std::process::exit(1); - } - - let mut paths = Vec::new(); - - match font::text_to_path(&text, "sans-serif", 72.0, Point::new(20.0, 100.0)) { - Ok(mut path) => { - path.fill = Some(Color::BLACK); - - // Calculate bounds for SVG size - let bounds = path.bounds().unwrap_or(nodebox_core::geometry::Rect::new(0.0, 0.0, 400.0, 150.0)); - paths.push(path); - - let svg = render_to_svg(&paths, bounds.x + bounds.width + 40.0, bounds.y + bounds.height + 40.0); - println!("{}", svg); - } - Err(e) => { - eprintln!("Error rendering text: {}", e); - std::process::exit(1); - } - } -} - -fn run_interactive() { - println!("NodeBox Interactive Mode"); - println!("Type 'help' for commands, 'quit' to exit.\n"); - - let mut paths: Vec = Vec::new(); - - loop { - print!("nodebox> "); - io::stdout().flush().unwrap(); - - let mut input = String::new(); - if io::stdin().read_line(&mut input).is_err() { - break; - } - - let input = input.trim(); - if input.is_empty() { - continue; - } - - let parts: Vec<&str> = input.split_whitespace().collect(); - let cmd = parts[0]; - let args = &parts[1..]; - - match cmd { - "quit" | "exit" | "q" => break, - - "help" | "?" => print_interactive_help(), - - "clear" => { - paths.clear(); - println!("Canvas cleared."); - } - - "list" => { - println!("{} paths on canvas:", paths.len()); - for (i, p) in paths.iter().enumerate() { - let bounds = p.bounds(); - let fill = p.fill.map(|c| format!("#{:02x}{:02x}{:02x}", - (c.r * 255.0) as u8, (c.g * 255.0) as u8, (c.b * 255.0) as u8)) - .unwrap_or_else(|| "none".to_string()); - match bounds { - Some(b) => println!(" [{}] bounds: ({:.0},{:.0}) {}x{}, fill: {}", - i, b.x, b.y, b.width, b.height, fill), - None => println!(" [{}] empty", i), - } - } - } - - "ellipse" | "circle" => { - let (x, y, w, h) = parse_rect_args(args, 100.0, 100.0, 80.0, 80.0); - let mut p = Path::ellipse(x, y, w, h); - p.fill = Some(Color::BLACK); - paths.push(p); - println!("Added ellipse at ({}, {}), size {}x{}", x, y, w, h); - } - - "rect" | "rectangle" => { - let (x, y, w, h) = parse_rect_args(args, 50.0, 50.0, 100.0, 80.0); - let p = rect(Point::new(x, y), w, h, Point::ZERO); - paths.push(p); - println!("Added rect at ({}, {}), size {}x{}", x, y, w, h); - } - - "star" => { - let (x, y, _, _) = parse_rect_args(args, 100.0, 100.0, 0.0, 0.0); - let points: u32 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(5); - let outer: f64 = args.get(5).and_then(|s| s.parse().ok()).unwrap_or(50.0); - let inner: f64 = args.get(6).and_then(|s| s.parse().ok()).unwrap_or(25.0); - let p = star(Point::new(x, y), points, outer, inner); - paths.push(p); - println!("Added {}-point star at ({}, {})", points, x, y); - } - - "polygon" => { - let (x, y, _, _) = parse_rect_args(args, 100.0, 100.0, 0.0, 0.0); - let sides: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(6); - let radius: f64 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(50.0); - let p = polygon(Point::new(x, y), radius, sides, true); - paths.push(p); - println!("Added {}-sided polygon at ({}, {})", sides, x, y); - } - - "text" => { - let text = args.join(" "); - if text.is_empty() { - println!("Usage: text "); - continue; - } - match font::text_to_path(&text, "sans-serif", 48.0, Point::new(20.0, 100.0)) { - Ok(p) => { - paths.push(p); - println!("Added text: \"{}\"", text); - } - Err(e) => println!("Error: {}", e), - } - } - - "color" => { - if paths.is_empty() { - println!("No paths to color. Add a shape first."); - continue; - } - let r: f64 = args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0.0); - let g: f64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(0.0); - let b: f64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); - if let Some(p) = paths.last_mut() { - p.fill = Some(Color::rgb(r, g, b)); - println!("Set color to ({}, {}, {})", r, g, b); - } - } - - "save" => { - let filename = args.get(0).unwrap_or(&"output.svg"); - let svg = render_to_svg(&paths, 500.0, 500.0); - match std::fs::write(filename, &svg) { - Ok(_) => println!("Saved to {}", filename), - Err(e) => println!("Error saving: {}", e), - } - } - - "show" => { - let svg = render_to_svg(&paths, 500.0, 500.0); - println!("{}", svg); - } - - _ => println!("Unknown command: {}. Type 'help' for commands.", cmd), - } - } - - println!("Goodbye!"); -} - -fn print_interactive_help() { - println!(r#" -Interactive Commands: - ellipse [x y w h] Add ellipse (default: 100 100 80 80) - rect [x y w h] Add rectangle - star [x y] [pts] [outer] [inner] Add star - polygon [x y] [sides] [radius] Add polygon - text Add text as path - - color r g b Set color of last shape (0.0-1.0) - clear Remove all shapes - list Show all shapes - - save [filename] Save to SVG file - show Print SVG to stdout - - help Show this help - quit Exit -"#); -} - -fn parse_rect_args(args: &[&str], dx: f64, dy: f64, dw: f64, dh: f64) -> (f64, f64, f64, f64) { - let x = args.get(0).and_then(|s| s.parse().ok()).unwrap_or(dx); - let y = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(dy); - let w = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(dw); - let h = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(dh); - (x, y, w, h) -} - -// Demo generators -fn demo_shapes() -> String { - let mut paths = Vec::new(); - - let mut circle = Path::ellipse(100.0, 100.0, 80.0, 80.0); - circle.fill = Some(Color::rgb(0.9, 0.2, 0.2)); - paths.push(circle); - - let mut rect = rect(Point::new(180.0, 60.0), 100.0, 80.0, Point::ZERO); - rect.fill = Some(Color::rgb(0.2, 0.8, 0.3)); - paths.push(rect); - - let mut star = star(Point::new(350.0, 100.0), 5, 50.0, 25.0); - star.fill = Some(Color::rgb(0.2, 0.4, 0.9)); - paths.push(star); - - let mut hex = polygon(Point::new(480.0, 100.0), 45.0, 6, true); - hex.fill = Some(Color::rgb(0.7, 0.3, 0.8)); - paths.push(hex); - - render_to_svg(&paths, 550.0, 200.0) -} - -fn demo_spiral() -> String { - use std::f64::consts::PI; - - let mut paths = Vec::new(); - let center = Point::new(250.0, 250.0); - - for i in 0..12 { - let radius = 30.0 + i as f64 * 18.0; - let hue = i as f64 / 12.0; - let color = hsb_to_rgb(hue, 0.7, 0.9); - - let mut circle = Path::ellipse(center.x, center.y, radius * 2.0, radius * 2.0); - circle.fill = None; - circle.stroke = Some(color); - circle.stroke_width = 3.0; - paths.push(circle); - } - - for i in 0..24 { - let angle = i as f64 * PI / 12.0; - let x1 = center.x + 25.0 * angle.cos(); - let y1 = center.y + 25.0 * angle.sin(); - let x2 = center.x + 230.0 * angle.cos(); - let y2 = center.y + 230.0 * angle.sin(); - - let mut line = Path::line(x1, y1, x2, y2); - line.stroke = Some(Color::rgba(0.3, 0.3, 0.3, 0.3)); - line.stroke_width = 1.0; - paths.push(line); - } - - render_to_svg(&paths, 500.0, 500.0) -} - -fn demo_text() -> String { - let mut paths = Vec::new(); - - if let Ok(mut path) = font::text_to_path("NodeBox", "sans-serif", 72.0, Point::new(50.0, 120.0)) { - path.fill = Some(Color::rgb(0.2, 0.2, 0.2)); - paths.push(path); - } - - if let Ok(mut path) = font::text_to_path("Rust", "sans-serif", 48.0, Point::new(50.0, 180.0)) { - path.fill = Some(Color::rgb(0.8, 0.3, 0.1)); - paths.push(path); - } - - let mut line = Path::line(50.0, 195.0, 200.0, 195.0); - line.stroke = Some(Color::rgb(0.8, 0.3, 0.1)); - line.stroke_width = 3.0; - paths.push(line); - - render_to_svg(&paths, 400.0, 220.0) -} - -fn demo_bezier() -> String { - let mut paths = Vec::new(); - - let ellipse = Path::ellipse(150.0, 150.0, 200.0, 150.0); - - let mut outline = ellipse.clone(); - outline.fill = None; - outline.stroke = Some(Color::rgba(0.5, 0.5, 0.5, 0.3)); - outline.stroke_width = 1.0; - paths.push(outline); - - let sample_points = ellipse.make_points(24); - - for (i, p) in sample_points.iter().enumerate() { - let hue = i as f64 / 24.0; - let color = hsb_to_rgb(hue, 0.8, 0.9); - - let mut dot = Path::ellipse(p.x, p.y, 12.0, 12.0); - dot.fill = Some(color); - paths.push(dot); - } - - for i in 0..sample_points.len() { - let p1 = sample_points[i]; - let p2 = sample_points[(i + 1) % sample_points.len()]; - - let mut line = Path::line(p1.x, p1.y, p2.x, p2.y); - line.stroke = Some(Color::rgba(0.3, 0.3, 0.3, 0.5)); - line.stroke_width = 1.0; - paths.push(line); - } - - render_to_svg(&paths, 300.0, 300.0) -} - -fn hsb_to_rgb(h: f64, s: f64, b: f64) -> Color { - let h = h * 6.0; - let i = h.floor() as i32; - let f = h - i as f64; - let p = b * (1.0 - s); - let q = b * (1.0 - s * f); - let t = b * (1.0 - s * (1.0 - f)); - - let (r, g, b) = match i % 6 { - 0 => (b, t, p), - 1 => (q, b, p), - 2 => (p, b, t), - 3 => (p, q, b), - 4 => (t, p, b), - _ => (b, p, q), - }; - - Color::rgb(r, g, b) -} diff --git a/crates/nodebox-core/Cargo.toml b/crates/nodebox-core/Cargo.toml index 26b30d75..a73f0177 100644 --- a/crates/nodebox-core/Cargo.toml +++ b/crates/nodebox-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nodebox-core" -description = "Core types and traits for NodeBox" +description = "Core types, operations, and platform abstraction for NodeBox" version.workspace = true edition.workspace = true license.workspace = true @@ -12,6 +12,25 @@ uuid = { workspace = true } font-kit = { workspace = true } pathfinder_geometry = { workspace = true } +# From nodebox-ops +rayon = "1.10" +usvg = "0.42" +csv = "1" + +# From nodebox-ndbx +quick-xml = { workspace = true } + +# From nodebox-ndbx + nodebox-port +thiserror = { workspace = true } + +# From nodebox-port +log = "0.4" + +[[bench]] +name = "clone_library" +harness = false + [dev-dependencies] proptest = { workspace = true } approx = { workspace = true } +tempfile = "3" diff --git a/crates/nodebox-core/benches/clone_library.rs b/crates/nodebox-core/benches/clone_library.rs new file mode 100644 index 00000000..0650ffa2 --- /dev/null +++ b/crates/nodebox-core/benches/clone_library.rs @@ -0,0 +1,162 @@ +//! Benchmark: NodeLibrary clone cost at various document sizes. +//! +//! Measures deep clone (`NodeLibrary::clone`) vs. `Arc::clone` to quantify +//! the potential benefit of a copy-on-write approach. + +use std::hint::black_box; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use nodebox_core::node::{Connection, Node, NodeLibrary, Port}; +use nodebox_core::geometry::Point; +use nodebox_core::Color; + +/// Build a realistic node with typical ports. +fn make_shape_node(name: &str, proto: &str) -> Node { + Node::new(name) + .with_prototype(proto) + .with_position(1.0, 2.0) + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::color("fill", Color::rgba(0.8, 0.2, 0.1, 1.0))) + .with_input(Port::color("stroke", Color::rgba(0.0, 0.0, 0.0, 1.0))) + .with_input(Port::float("strokeWidth", 1.0)) +} + +/// Build a NodeLibrary with `n` nodes and ~n connections between them. +fn build_library(node_count: usize) -> NodeLibrary { + let mut library = NodeLibrary::new("bench_document"); + library.set_width(1000.0); + library.set_height(1000.0); + + let protos = [ + "corevector.ellipse", + "corevector.rect", + "corevector.polygon", + "corevector.star", + "corevector.colorize", + "corevector.translate", + "corevector.rotate", + "corevector.scale", + ]; + + let mut root = Node::network("root"); + + // Create nodes + for i in 0..node_count { + let proto = protos[i % protos.len()]; + let name = format!("node{}", i); + root.children.push(make_shape_node(&name, proto)); + } + + // Create connections: each node (except the first) connects to the previous one + for i in 1..node_count { + root.connections.push(Connection::new( + format!("node{}", i - 1), + format!("node{}", i), + "shape", + )); + } + + if node_count > 0 { + root.rendered_child = Some(format!("node{}", node_count - 1)); + } + + library.root = root; + library +} + +/// Time a closure over `iterations` runs, return (total, per-iteration average). +fn bench(mut f: F, iterations: u32) -> (Duration, Duration) { + // Warmup + for _ in 0..iterations.min(100) { + f(); + } + + let start = Instant::now(); + for _ in 0..iterations { + f(); + } + let total = start.elapsed(); + (total, total / iterations) +} + +fn main() { + let sizes = [5, 20, 50, 100, 200]; + let iterations = 10_000; + + println!("NodeLibrary Clone Benchmark"); + println!("==========================="); + println!("Iterations per measurement: {iterations}\n"); + + println!( + "{:>6} | {:>12} | {:>12} | {:>12} | {:>10}", + "Nodes", "Deep Clone", "Arc::clone", "Arc+make_mut", "Speedup" + ); + println!("{}", "-".repeat(72)); + + for &n in &sizes { + let lib = build_library(n); + let arc_lib = Arc::new(build_library(n)); + + // 1. Deep clone (current behavior: what happens every render dispatch) + let (_, deep_avg) = bench( + || { + black_box(lib.clone()); + }, + iterations, + ); + + // 2. Arc::clone (proposed: what render dispatch would cost with Arc) + let (_, arc_avg) = bench( + || { + black_box(Arc::clone(&arc_lib)); + }, + iterations, + ); + + // 3. Arc::clone + Arc::make_mut (proposed: worst case mutation while render holds ref) + // Simulates: render holds an Arc, UI needs to mutate → COW clone triggered + let (_, cow_avg) = bench( + || { + let render_ref = Arc::clone(&arc_lib); // render worker holds this + let mut ui_ref = Arc::clone(&arc_lib); // UI holds this + Arc::make_mut(&mut ui_ref); // triggers COW clone + black_box(render_ref); + black_box(ui_ref); + }, + iterations, + ); + + let speedup = deep_avg.as_nanos() as f64 / arc_avg.as_nanos().max(1) as f64; + + println!( + "{:>6} | {:>9.1?} | {:>9.1?} | {:>9.1?} | {:>8.0}x", + n, deep_avg, arc_avg, cow_avg, speedup + ); + } + + println!(); + println!("Legend:"); + println!(" Deep Clone = full NodeLibrary::clone() [current, every render]"); + println!(" Arc::clone = Arc reference count bump [proposed, every render]"); + println!(" Arc+make_mut = COW clone [proposed, only when mutating during active render]"); + + // Also measure memory: approximate size of the library by serializing + println!(); + println!("Approximate struct sizes (std::mem::size_of):"); + println!(" NodeLibrary: {} bytes", std::mem::size_of::()); + println!(" Node: {} bytes", std::mem::size_of::()); + println!(" Port: {} bytes", std::mem::size_of::()); + println!(" Connection: {} bytes", std::mem::size_of::()); + + // Rough heap size estimate for largest library + let large_lib = build_library(200); + let node_count = large_lib.root.children.len(); + let conn_count = large_lib.root.connections.len(); + let port_count: usize = large_lib.root.children.iter().map(|n| n.inputs.len()).sum(); + println!(); + println!("200-node library stats:"); + println!(" {node_count} nodes, {conn_count} connections, {port_count} total ports"); +} diff --git a/crates/nodebox-svg/examples/generate_svg.rs b/crates/nodebox-core/examples/generate_svg.rs similarity index 99% rename from crates/nodebox-svg/examples/generate_svg.rs rename to crates/nodebox-core/examples/generate_svg.rs index 9e625bbb..ca43f3b0 100644 --- a/crates/nodebox-svg/examples/generate_svg.rs +++ b/crates/nodebox-core/examples/generate_svg.rs @@ -1,7 +1,7 @@ //! Generate an SVG file showcasing NodeBox capabilities. use nodebox_core::geometry::{Path, Color, Point, font}; -use nodebox_svg::render_to_svg; +use nodebox_core::svg::render_to_svg; use std::f64::consts::PI; fn main() { diff --git a/crates/nodebox-ops/examples/ops_demo.rs b/crates/nodebox-core/examples/ops_demo.rs similarity index 99% rename from crates/nodebox-ops/examples/ops_demo.rs rename to crates/nodebox-core/examples/ops_demo.rs index 263aea96..2f38e4e0 100644 --- a/crates/nodebox-ops/examples/ops_demo.rs +++ b/crates/nodebox-core/examples/ops_demo.rs @@ -1,7 +1,7 @@ //! Demo of NodeBox geometry operations. use nodebox_core::geometry::Point; -use nodebox_ops::*; +use nodebox_core::ops::*; fn main() { println!("=== NodeBox Ops Demo ===\n"); diff --git a/crates/nodebox-svg/examples/text_dots.rs b/crates/nodebox-core/examples/text_dots.rs similarity index 98% rename from crates/nodebox-svg/examples/text_dots.rs rename to crates/nodebox-core/examples/text_dots.rs index c77f2b79..887b5554 100644 --- a/crates/nodebox-svg/examples/text_dots.rs +++ b/crates/nodebox-core/examples/text_dots.rs @@ -6,7 +6,7 @@ //! 3. Draw dots at each sample point use nodebox_core::geometry::{Path, Color, Point, font}; -use nodebox_svg::render_to_svg; +use nodebox_core::svg::render_to_svg; fn main() { let text = std::env::args().nth(1).unwrap_or_else(|| "NodeBox".to_string()); diff --git a/crates/nodebox-core/src/geometry/contour.rs b/crates/nodebox-core/src/geometry/contour.rs index d084ef68..c9370f8e 100644 --- a/crates/nodebox-core/src/geometry/contour.rs +++ b/crates/nodebox-core/src/geometry/contour.rs @@ -75,6 +75,12 @@ impl Contour { self.points.push(PathPoint::curve_to(x3, y3)); } + /// Adds a quadratic Bezier curve to (x2, y2) with control point (x1, y1). + pub fn quad_to(&mut self, cx: f64, cy: f64, x: f64, y: f64) { + self.points.push(PathPoint::quad_data(cx, cy)); + self.points.push(PathPoint::quad_to(x, y)); + } + /// Closes this contour. pub fn close(&mut self) { self.closed = true; @@ -131,7 +137,8 @@ impl Contour { /// Returns the segments of this contour. /// - /// A segment is either a line or a cubic bezier curve between two on-curve points. + /// A segment is either a line, a cubic bezier curve, or a quadratic bezier curve + /// between two on-curve points. pub fn segments(&self) -> Vec { if self.points.is_empty() { return Vec::new(); @@ -159,6 +166,17 @@ impl Contour { // Malformed curve, skip to end break; } + } else if next.point_type == PointType::QuadData { + // This is a quadratic bezier: start, ctrl, end + if i + 2 < self.points.len() { + let ctrl = self.points[i + 1].point; + let end = self.points[i + 2].point; + segments.push(Segment::Quadratic { start, ctrl, end }); + i += 2; + } else { + // Malformed curve, skip to end + break; + } } else { // Line segment segments.push(Segment::Line { start, end: next.point }); @@ -328,7 +346,7 @@ impl Contour { } } -/// A segment of a contour - either a line or a cubic bezier curve. +/// A segment of a contour - either a line, a cubic bezier curve, or a quadratic bezier curve. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Segment { /// A straight line segment. @@ -340,6 +358,12 @@ pub enum Segment { ctrl2: Point, end: Point, }, + /// A quadratic bezier curve segment. + Quadratic { + start: Point, + ctrl: Point, + end: Point, + }, } impl Segment { @@ -353,6 +377,9 @@ impl Segment { Segment::Cubic { start, ctrl1, ctrl2, end } => { Self::cubic_bezier_point(*start, *ctrl1, *ctrl2, *end, t) } + Segment::Quadratic { start, ctrl, end } => { + Self::quadratic_bezier_point(*start, *ctrl, *end, t) + } } } @@ -363,6 +390,9 @@ impl Segment { Segment::Cubic { start, ctrl1, ctrl2, end } => { Self::cubic_bezier_length(*start, *ctrl1, *ctrl2, *end) } + Segment::Quadratic { start, ctrl, end } => { + Self::quadratic_bezier_length(*start, *ctrl, *end) + } } } @@ -398,6 +428,34 @@ impl Segment { length } + + /// Evaluates a quadratic bezier curve using De Casteljau's algorithm. + /// + /// This is numerically stable and works for any t in [0, 1]. + fn quadratic_bezier_point(p0: Point, p1: Point, p2: Point, t: f64) -> Point { + // De Casteljau's algorithm for quadratic bezier + // Level 1 + let q0 = p0.lerp(p1, t); + let q1 = p1.lerp(p2, t); + + // Level 2 (final point) + q0.lerp(q1, t) + } + + /// Approximates the arc length of a quadratic bezier using subdivision. + fn quadratic_bezier_length(p0: Point, p1: Point, p2: Point) -> f64 { + let mut length = 0.0; + let mut prev = p0; + + for i in 1..=Self::BEZIER_SUBDIVISIONS { + let t = i as f64 / Self::BEZIER_SUBDIVISIONS as f64; + let current = Self::quadratic_bezier_point(p0, p1, p2, t); + length += prev.distance_to(current); + prev = current; + } + + length + } } #[cfg(test)] @@ -972,4 +1030,159 @@ mod tests { assert!((p.x - 0.5).abs() < 0.01); assert!((p.y - 0.5).abs() < 0.01); } + + // ======================================================================== + // Quadratic Bezier Tests + // ======================================================================== + + #[test] + fn test_contour_quad_to() { + let mut c = Contour::new(); + c.move_to(0.0, 0.0); + c.quad_to(50.0, 100.0, 100.0, 0.0); + + assert_eq!(c.len(), 3); // 1 move + 1 control + 1 quad-to + assert_eq!(c.point_count(), 2); // Only on-curve points + } + + #[test] + fn test_quad_to_segment_detection() { + let mut c = Contour::new(); + c.move_to(0.0, 0.0); + c.quad_to(50.0, 100.0, 100.0, 0.0); + + let segments = c.segments(); + assert_eq!(segments.len(), 1); + match &segments[0] { + Segment::Quadratic { start, ctrl, end } => { + assert_eq!(*start, Point::new(0.0, 0.0)); + assert_eq!(*ctrl, Point::new(50.0, 100.0)); + assert_eq!(*end, Point::new(100.0, 0.0)); + } + _ => panic!("Expected Quadratic segment"), + } + } + + #[test] + fn test_segment_quadratic_endpoints() { + let seg = Segment::Quadratic { + start: Point::new(0.0, 0.0), + ctrl: Point::new(50.0, 100.0), + end: Point::new(100.0, 0.0), + }; + + let p0 = seg.point_at(0.0); + let p1 = seg.point_at(1.0); + + assert_eq!(p0, Point::new(0.0, 0.0)); + assert_eq!(p1, Point::new(100.0, 0.0)); + } + + #[test] + fn test_segment_quadratic_midpoint() { + // Symmetric quadratic curve - midpoint should be at x=50 + let seg = Segment::Quadratic { + start: Point::new(0.0, 0.0), + ctrl: Point::new(50.0, 100.0), + end: Point::new(100.0, 0.0), + }; + + let p_half = seg.point_at(0.5); + assert!((p_half.x - 50.0).abs() < 0.001); + // For quadratic: B(t) = (1-t)^2*P0 + 2*(1-t)*t*P1 + t^2*P2 + // At t=0.5: 0.25*0 + 0.5*100 + 0.25*0 = 50 for Y + assert!((p_half.y - 50.0).abs() < 0.001); + } + + #[test] + fn test_segment_straight_quadratic_length() { + // A "straight" quadratic bezier + let seg = Segment::Quadratic { + start: Point::new(0.0, 0.0), + ctrl: Point::new(50.0, 0.0), + end: Point::new(100.0, 0.0), + }; + + // Length should be approximately 100 + assert!((seg.length() - 100.0).abs() < 1.0); + } + + #[test] + fn test_quadratic_point_at() { + let mut c = Contour::new(); + c.move_to(0.0, 0.0); + c.quad_to(50.0, 100.0, 100.0, 0.0); + + // At t=0, should be at start + let p0 = c.point_at(0.0); + assert!((p0.x - 0.0).abs() < 0.001); + assert!((p0.y - 0.0).abs() < 0.001); + + // At t=1, should be at end + let p1 = c.point_at(1.0); + assert!((p1.x - 100.0).abs() < 0.001); + assert!((p1.y - 0.0).abs() < 0.001); + + // At t=0.5, should be at peak of the curve + let p_half = c.point_at(0.5); + assert!((p_half.x - 50.0).abs() < 1.0); + assert!(p_half.y > 0.0); // Should be above the baseline + } + + #[test] + fn test_quadratic_length() { + let mut c = Contour::new(); + c.move_to(0.0, 0.0); + c.quad_to(50.0, 0.0, 100.0, 0.0); + + // A straight quadratic should have length approximately 100 + assert!((c.length() - 100.0).abs() < 1.0); + } + + #[test] + fn test_quadratic_make_points() { + let mut c = Contour::new(); + c.move_to(0.0, 0.0); + c.quad_to(50.0, 100.0, 100.0, 0.0); + + let points = c.make_points(5); + assert_eq!(points.len(), 5); + + // First and last points should be at endpoints + assert!((points[0].x - 0.0).abs() < 0.001); + assert!((points[4].x - 100.0).abs() < 0.001); + } + + #[test] + fn test_quadratic_resample() { + let mut c = Contour::new(); + c.move_to(0.0, 0.0); + c.quad_to(50.0, 100.0, 100.0, 0.0); + + let resampled = c.resample_by_amount(10); + assert_eq!(resampled.points.len(), 10); + + // First and last points should match original endpoints + assert!((resampled.points[0].x() - 0.0).abs() < 0.001); + assert!((resampled.points[0].y() - 0.0).abs() < 0.001); + assert!((resampled.points[9].x() - 100.0).abs() < 0.001); + assert!((resampled.points[9].y() - 0.0).abs() < 0.001); + } + + #[test] + fn test_mixed_segments() { + // Test a contour with line, cubic, and quadratic segments + let mut c = Contour::new(); + c.move_to(0.0, 0.0); + c.line_to(50.0, 0.0); // Line + c.quad_to(75.0, 50.0, 100.0, 0.0); // Quadratic + c.curve_to(125.0, -50.0, 175.0, -50.0, 200.0, 0.0); // Cubic + + let segments = c.segments(); + assert_eq!(segments.len(), 3); + + assert!(matches!(segments[0], Segment::Line { .. })); + assert!(matches!(segments[1], Segment::Quadratic { .. })); + assert!(matches!(segments[2], Segment::Cubic { .. })); + } } diff --git a/crates/nodebox-core/src/geometry/path.rs b/crates/nodebox-core/src/geometry/path.rs index 3afa5af8..b470a619 100644 --- a/crates/nodebox-core/src/geometry/path.rs +++ b/crates/nodebox-core/src/geometry/path.rs @@ -99,6 +99,11 @@ impl Path { self.current_contour().curve_to(x1, y1, x2, y2, x3, y3); } + /// Adds a quadratic Bezier curve to the current contour. + pub fn quad_to(&mut self, cx: f64, cy: f64, x: f64, y: f64) { + self.current_contour().quad_to(cx, cy, x, y); + } + /// Closes the current contour. pub fn close(&mut self) { if let Some(contour) = self.contours.last_mut() { diff --git a/crates/nodebox-core/src/geometry/point.rs b/crates/nodebox-core/src/geometry/point.rs index b66af834..cb155db4 100644 --- a/crates/nodebox-core/src/geometry/point.rs +++ b/crates/nodebox-core/src/geometry/point.rs @@ -166,19 +166,23 @@ pub enum PointType { CurveTo, /// This is a control point for a cubic Bezier curve (not on the curve itself). CurveData, + /// This is the endpoint of a quadratic Bezier curve. + QuadTo, + /// This is a control point for a quadratic Bezier curve (not on the curve itself). + QuadData, } impl PointType { - /// Returns true if this point is on the curve (LineTo or CurveTo). + /// Returns true if this point is on the curve (LineTo, CurveTo, or QuadTo). #[inline] pub fn is_on_curve(self) -> bool { - !matches!(self, PointType::CurveData) + matches!(self, PointType::LineTo | PointType::CurveTo | PointType::QuadTo) } /// Returns true if this point is off the curve (a control point). #[inline] pub fn is_off_curve(self) -> bool { - matches!(self, PointType::CurveData) + matches!(self, PointType::CurveData | PointType::QuadData) } } @@ -220,6 +224,18 @@ impl PathPoint { PathPoint::new(x, y, PointType::CurveData) } + /// Creates a QuadTo path point (endpoint of a quadratic Bezier curve). + #[inline] + pub const fn quad_to(x: f64, y: f64) -> Self { + PathPoint::new(x, y, PointType::QuadTo) + } + + /// Creates a QuadData path point (control point for quadratic Bezier). + #[inline] + pub const fn quad_data(x: f64, y: f64) -> Self { + PathPoint::new(x, y, PointType::QuadData) + } + /// Returns the x coordinate. #[inline] pub fn x(&self) -> f64 { @@ -354,8 +370,11 @@ mod tests { fn test_point_type() { assert!(PointType::LineTo.is_on_curve()); assert!(PointType::CurveTo.is_on_curve()); + assert!(PointType::QuadTo.is_on_curve()); assert!(!PointType::CurveData.is_on_curve()); + assert!(!PointType::QuadData.is_on_curve()); assert!(PointType::CurveData.is_off_curve()); + assert!(PointType::QuadData.is_off_curve()); } #[test] @@ -370,6 +389,14 @@ mod tests { let pp3 = PathPoint::curve_data(50.0, 60.0); assert_eq!(pp3.point_type, PointType::CurveData); + + let pp4 = PathPoint::quad_to(70.0, 80.0); + assert_eq!(pp4.point_type, PointType::QuadTo); + assert_eq!(pp4.x(), 70.0); + assert_eq!(pp4.y(), 80.0); + + let pp5 = PathPoint::quad_data(90.0, 100.0); + assert_eq!(pp5.point_type, PointType::QuadData); } #[test] diff --git a/crates/nodebox-core/src/lib.rs b/crates/nodebox-core/src/lib.rs index 1689f631..a2f95045 100644 --- a/crates/nodebox-core/src/lib.rs +++ b/crates/nodebox-core/src/lib.rs @@ -1,13 +1,21 @@ //! NodeBox Core Library //! -//! This crate provides the core types and traits for NodeBox: +//! This crate provides the core types, operations, and platform abstraction for NodeBox: //! - Geometry primitives (Point, Rect, Path, etc.) //! - Node graph model (Node, Port, Connection) //! - Runtime value types +//! - Geometry operations (generators, filters, math, etc.) +//! - NDBX file format (parse/serialize) +//! - SVG rendering +//! - Platform abstraction (Platform trait) pub mod geometry; pub mod node; pub mod value; +pub mod ops; +pub mod ndbx; +pub mod svg; +pub mod platform; // Re-export commonly used types at the crate root pub use geometry::{ diff --git a/crates/nodebox-ndbx/src/error.rs b/crates/nodebox-core/src/ndbx/error.rs similarity index 100% rename from crates/nodebox-ndbx/src/error.rs rename to crates/nodebox-core/src/ndbx/error.rs diff --git a/crates/nodebox-core/src/ndbx/mod.rs b/crates/nodebox-core/src/ndbx/mod.rs new file mode 100644 index 00000000..9408a630 --- /dev/null +++ b/crates/nodebox-core/src/ndbx/mod.rs @@ -0,0 +1,14 @@ +//! NDBX file format parser and serializer for NodeBox. +//! +//! Parses `.ndbx` files (XML-based) into NodeBox's internal +//! node graph representation, and serializes them back to XML. + +mod error; +mod parser; +mod serializer; +mod upgrades; + +pub use error::{NdbxError, Result}; +pub use parser::{parse, parse_file, parse_file_with_warnings}; +pub use serializer::{serialize, serialize_to_file}; +pub use upgrades::{upgrade, UpgradeResult, CURRENT_FORMAT_VERSION, MIN_SUPPORTED_VERSION}; diff --git a/crates/nodebox-ndbx/src/parser.rs b/crates/nodebox-core/src/ndbx/parser.rs similarity index 95% rename from crates/nodebox-ndbx/src/parser.rs rename to crates/nodebox-core/src/ndbx/parser.rs index ee4fa8f3..30ec6b16 100644 --- a/crates/nodebox-ndbx/src/parser.rs +++ b/crates/nodebox-core/src/ndbx/parser.rs @@ -6,16 +6,31 @@ use std::path::Path; use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader; -use nodebox_core::node::{Connection, MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; -use nodebox_core::geometry::Point; -use nodebox_core::Value; - -use crate::error::{NdbxError, Result}; +use crate::node::{Connection, MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; +use crate::geometry::Point; +use crate::Value; + +use super::error::{NdbxError, Result}; +use super::upgrades::upgrade; + +/// Parses an NDBX file from the given path, returning the library and any warnings. +/// +/// Unlike `parse_file`, this function also returns non-fatal warnings +/// (e.g., when loading old format versions best-effort). +pub fn parse_file_with_warnings(path: impl AsRef) -> Result<(NodeLibrary, Vec)> { + let content = fs::read_to_string(path)?; + let mut library = parse(&content)?; + let upgrade_result = upgrade(&mut library)?; + Ok((library, upgrade_result.warnings)) +} /// Parses an NDBX file from the given path. +/// +/// After parsing, the library is automatically upgraded to the current format version. +/// Warnings from upgrading old format versions are discarded. pub fn parse_file(path: impl AsRef) -> Result { - let content = fs::read_to_string(path)?; - parse(&content) + let (library, _warnings) = parse_file_with_warnings(path)?; + Ok(library) } /// Parses NDBX content from a string. @@ -475,7 +490,7 @@ fn parse_value(s: &str, port_type: &PortType) -> Value { .unwrap_or(Value::Null) } PortType::Color => { - nodebox_core::Color::from_hex(s) + crate::Color::from_hex(s) .map(Value::Color) .unwrap_or(Value::Null) } diff --git a/crates/nodebox-core/src/ndbx/serializer.rs b/crates/nodebox-core/src/ndbx/serializer.rs new file mode 100644 index 00000000..c1c8ce73 --- /dev/null +++ b/crates/nodebox-core/src/ndbx/serializer.rs @@ -0,0 +1,533 @@ +//! XML serializer for .ndbx files. +//! +//! This module converts NodeLibrary data structures back to XML format. + +use std::fs; +use std::io::Write; +use std::path::Path; + +use crate::node::{Connection, MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; +use crate::Value; + +use super::error::Result; +use super::upgrades::CURRENT_FORMAT_VERSION; + +/// Serializes a NodeLibrary to an XML string. +pub fn serialize(library: &NodeLibrary) -> String { + let mut output = String::new(); + output.push_str(r#""#); + output.push('\n'); + + // Start ndbx element + output.push_str(&format!( + r#"\n"); + + // Write properties (sorted for deterministic output) + let mut properties: Vec<_> = library.properties.iter().collect(); + properties.sort_by_key(|(k, _)| *k); + + for (name, value) in properties { + // Skip link properties and the "type" property (it's in the ndbx element) + if name.starts_with("link.") || name == "type" { + continue; + } + output.push_str(&format!( + r#" "#, + escape_xml(name), + escape_xml(value) + )); + output.push('\n'); + } + + // Write root node + write_node(&mut output, &library.root, 1); + + output.push_str("\n"); + + output +} + +/// Serializes a NodeLibrary to a file. +pub fn serialize_to_file(library: &NodeLibrary, path: impl AsRef) -> Result<()> { + let content = serialize(library); + let mut file = fs::File::create(path)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +fn write_node(output: &mut String, node: &Node, indent: usize) { + let indent_str = " ".repeat(indent); + + // Start node element + output.push_str(&indent_str); + output.push_str(&format!(r#"\n"); + + // Write input ports + for port in &node.inputs { + write_port(output, port, indent + 1); + } + + // Write child nodes + for child in &node.children { + write_node(output, child, indent + 1); + } + + // Write connections + for conn in &node.connections { + write_connection(output, conn, indent + 1); + } + + output.push_str(&indent_str); + output.push_str("\n"); + } else { + output.push_str("/>\n"); + } +} + +fn write_port(output: &mut String, port: &Port, indent: usize) { + let indent_str = " ".repeat(indent); + + output.push_str(&indent_str); + output.push_str(&format!(r#"\n"); + + for item in &port.menu_items { + write_menu_item(output, item, indent + 1); + } + + output.push_str(&indent_str); + output.push_str("\n"); + } else { + output.push_str("/>\n"); + } +} + +fn write_menu_item(output: &mut String, item: &MenuItem, indent: usize) { + let indent_str = " ".repeat(indent); + output.push_str(&indent_str); + output.push_str(&format!( + r#""#, + escape_xml(&item.key), + escape_xml(&item.label) + )); + output.push('\n'); +} + +fn write_connection(output: &mut String, conn: &Connection, indent: usize) { + let indent_str = " ".repeat(indent); + output.push_str(&indent_str); + output.push_str(&format!( + r#""#, + escape_xml(&conn.input_node), + escape_xml(&conn.input_port), + escape_xml(&conn.output_node) + )); + output.push('\n'); +} + +fn format_port_type(port_type: &PortType) -> &str { + port_type.as_str() +} + +fn format_widget(widget: &Widget) -> &str { + match widget { + Widget::None => "none", + Widget::Int => "int", + Widget::Float => "float", + Widget::Angle => "angle", + Widget::String => "string", + Widget::Text => "text", + Widget::Password => "password", + Widget::Toggle => "toggle", + Widget::Color => "color", + Widget::Point => "point", + Widget::Menu => "menu", + Widget::File => "file", + Widget::Font => "font", + Widget::Image => "image", + Widget::Data => "data", + Widget::Seed => "seed", + Widget::Gradient => "gradient", + } +} + +fn format_value(value: &Value, _port_type: &PortType) -> Option { + match value { + Value::Null => None, + Value::Int(i) => Some(i.to_string()), + Value::Float(f) => Some(format_float(*f)), + Value::String(s) => Some(s.clone()), + Value::Boolean(b) => Some(if *b { "true".to_string() } else { "false".to_string() }), + Value::Point(p) => Some(format!("{:.2},{:.2}", p.x, p.y)), + Value::Color(c) => Some(c.to_hex().to_lowercase()), + Value::Path(_) | Value::Geometry(_) | Value::List(_) | Value::Map(_) => None, + } +} + +fn format_float(f: f64) -> String { + // Format floats consistently - use decimal point, avoid scientific notation + if f.fract() == 0.0 { + format!("{:.1}", f) + } else { + // Remove trailing zeros but keep at least one decimal place + let s = format!("{:.6}", f); + let s = s.trim_end_matches('0'); + if s.ends_with('.') { + format!("{}0", s) + } else { + s.to_string() + } + } +} + +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::{Color, Point}; + use crate::node::Connection; + + #[test] + fn test_serialize_empty_library() { + let library = NodeLibrary::new("test"); + let xml = serialize(&library); + + assert!(xml.contains(r#"formatVersion="22""#)); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#""#)); + } + + #[test] + fn test_serialize_node_with_position() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_position(1.5, 2.5) + ); + let xml = serialize(&library); + + assert!(xml.contains(r#"name="rect1""#)); + assert!(xml.contains(r#"prototype="corevector.rect""#)); + assert!(xml.contains(r#"position="1.50,2.50""#)); + } + + #[test] + fn test_serialize_port_with_float_value() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::float("width", 100.0)) + ); + let xml = serialize(&library); + + assert!(xml.contains(r#""#)); + } + + #[test] + fn test_serialize_port_with_color_value() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_input(Port::color("fill", Color::rgba(1.0, 0.0, 0.0, 1.0))) + ); + let xml = serialize(&library); + + assert!(xml.contains(r#"type="color""#)); + assert!(xml.contains("value=\"#ff0000ff\"")); + } + + #[test] + fn test_serialize_port_with_point_value() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_input(Port::point("position", Point::new(10.5, -20.25))) + ); + let xml = serialize(&library); + + assert!(xml.contains(r#"type="point""#)); + assert!(xml.contains(r#"value="10.50,-20.25""#)); + } + + #[test] + fn test_serialize_port_with_boolean_value() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("connect1") + .with_input(Port::boolean("closed", true)) + ); + let xml = serialize(&library); + + assert!(xml.contains(r#"type="boolean""#)); + assert!(xml.contains(r#"value="true""#)); + } + + #[test] + fn test_serialize_connection() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child(Node::new("rect1")) + .with_child(Node::new("colorize1")) + .with_connection(Connection::new("rect1", "colorize1", "shape")) + .with_rendered_child("colorize1"); + let xml = serialize(&library); + + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#"renderedChild="colorize1""#)); + } + + #[test] + fn test_serialize_port_with_menu() { + let mut library = NodeLibrary::new("test"); + let mut port = Port::string("halign", "center"); + port.widget = Widget::Menu; + port.menu_items = vec![ + MenuItem::new("left", "Left"), + MenuItem::new("center", "Center"), + MenuItem::new("right", "Right"), + ]; + + library.root = Node::network("root") + .with_child( + Node::new("align1") + .with_input(port) + ); + let xml = serialize(&library); + + assert!(xml.contains(r#"widget="menu""#)); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#""#)); + } + + #[test] + fn test_escape_xml() { + assert_eq!(escape_xml("hello"), "hello"); + assert_eq!(escape_xml("<>&\"'"), "<>&"'"); + } + + #[test] + fn test_format_float() { + assert_eq!(format_float(100.0), "100.0"); + assert_eq!(format_float(10.5), "10.5"); + assert_eq!(format_float(3.14159), "3.14159"); + } + + #[test] + fn test_round_trip() { + use crate::ndbx::parse; + + // Create a library with various node types and properties + let mut original = NodeLibrary::new("test"); + original.uuid = Some("test-uuid-456".to_string()); + original.format_version = 22; + original.set_width(800.0); + original.set_height(600.0); + + original.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_position(1.0, 2.0) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 50.0)) + .with_input(Port::point("position", Point::new(-50.0, 25.0))) + ) + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_position(1.0, 4.0) + .with_input(Port::color("fill", Color::rgba(1.0, 0.5, 0.0, 1.0))) + .with_input(Port::boolean("enabled", true)) + ) + .with_connection(Connection::new("rect1", "colorize1", "shape")) + .with_rendered_child("colorize1"); + + // Serialize to XML + let xml = serialize(&original); + + // Parse back + let parsed = parse(&xml).expect("Failed to parse serialized XML"); + + // Verify key properties + assert_eq!(parsed.format_version, 22); + assert_eq!(parsed.uuid, Some("test-uuid-456".to_string())); + assert_eq!(parsed.width(), 800.0); + assert_eq!(parsed.height(), 600.0); + + // Verify root node + assert_eq!(parsed.root.name, "root"); + assert_eq!(parsed.root.rendered_child, Some("colorize1".to_string())); + assert_eq!(parsed.root.children.len(), 2); + assert_eq!(parsed.root.connections.len(), 1); + + // Verify rect1 node + let rect = parsed.root.child("rect1").expect("Missing rect1"); + assert_eq!(rect.prototype, Some("corevector.rect".to_string())); + assert_eq!(rect.position.x, 1.0); + assert_eq!(rect.position.y, 2.0); + assert_eq!(rect.inputs.len(), 3); + + let width_port = rect.input("width").expect("Missing width port"); + assert_eq!(width_port.value.as_float(), Some(100.0)); + + // Verify colorize1 node + let colorize = parsed.root.child("colorize1").expect("Missing colorize1"); + let fill_port = colorize.input("fill").expect("Missing fill port"); + if let Value::Color(c) = &fill_port.value { + assert!((c.r - 1.0).abs() < 0.01); + assert!((c.g - 0.5).abs() < 0.01); + assert!((c.b - 0.0).abs() < 0.01); + } else { + panic!("Expected color value for fill port"); + } + + // Verify connection + let conn = &parsed.root.connections[0]; + assert_eq!(conn.output_node, "rect1"); + assert_eq!(conn.input_node, "colorize1"); + assert_eq!(conn.input_port, "shape"); + } +} diff --git a/crates/nodebox-core/src/ndbx/upgrades.rs b/crates/nodebox-core/src/ndbx/upgrades.rs new file mode 100644 index 00000000..83ed96a4 --- /dev/null +++ b/crates/nodebox-core/src/ndbx/upgrades.rs @@ -0,0 +1,105 @@ +//! Version upgrades for .ndbx files. +//! +//! This module handles upgrading older .ndbx file formats to the current version. +//! Old versions (< 21) are loaded best-effort with a warning. + +use crate::node::NodeLibrary; + +use super::error::NdbxError; + +/// The current format version used when saving .ndbx files. +pub const CURRENT_FORMAT_VERSION: u32 = 22; + +/// The minimum format version we can load without warnings (Java's latest). +pub const MIN_SUPPORTED_VERSION: u32 = 21; + +/// Result of upgrading a NodeLibrary, with optional warnings. +#[derive(Debug, Default)] +pub struct UpgradeResult { + /// Non-fatal warnings encountered during upgrade. + pub warnings: Vec, +} + +/// Upgrades a NodeLibrary to the current format version. +/// +/// Old versions (< 21) are upgraded best-effort with a warning. +/// Future versions (> current) return an error since they may contain +/// structures we cannot parse. +pub fn upgrade(library: &mut NodeLibrary) -> Result { + let mut result = UpgradeResult::default(); + + match library.format_version { + v if v < MIN_SUPPORTED_VERSION => { + result.warnings.push(format!( + "This file uses format version {}, which is older than the supported \ + version {}. Some features may not work correctly.", + v, MIN_SUPPORTED_VERSION + )); + library.format_version = CURRENT_FORMAT_VERSION; + Ok(result) + } + 21 => { + // Upgrade from Java version 21 to Rust version 22 + // Currently a no-op (format is compatible) + library.format_version = CURRENT_FORMAT_VERSION; + Ok(result) + } + 22 => Ok(result), // Already current + v => Err(NdbxError::UnsupportedVersion(v)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_upgrade_v21_to_v22() { + let mut library = NodeLibrary::default(); + library.format_version = 21; + + let result = upgrade(&mut library).unwrap(); + assert_eq!(library.format_version, 22); + assert!(result.warnings.is_empty()); + } + + #[test] + fn test_upgrade_v22_no_change() { + let mut library = NodeLibrary::default(); + library.format_version = 22; + + let result = upgrade(&mut library).unwrap(); + assert_eq!(library.format_version, 22); + assert!(result.warnings.is_empty()); + } + + #[test] + fn test_upgrade_old_version_warns() { + let mut library = NodeLibrary::default(); + library.format_version = 20; + + let result = upgrade(&mut library).unwrap(); + assert!(!result.warnings.is_empty(), "Old version should produce a warning"); + assert_eq!(library.format_version, CURRENT_FORMAT_VERSION); + } + + #[test] + fn test_upgrade_very_old_version_warns() { + let mut library = NodeLibrary::default(); + library.format_version = 17; + + let result = upgrade(&mut library).unwrap(); + assert!(!result.warnings.is_empty()); + assert!(result.warnings[0].contains("17")); + assert_eq!(library.format_version, CURRENT_FORMAT_VERSION); + } + + #[test] + fn test_upgrade_future_version_error() { + let mut library = NodeLibrary::default(); + library.format_version = 99; + + let result = upgrade(&mut library); + assert!(result.is_err()); + } +} diff --git a/crates/nodebox-core/src/node/library.rs b/crates/nodebox-core/src/node/library.rs index b603df9e..89218818 100644 --- a/crates/nodebox-core/src/node/library.rs +++ b/crates/nodebox-core/src/node/library.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use super::Node; +use super::PortType; +use crate::geometry::Color; /// A library of nodes, typically loaded from an .ndbx file. /// @@ -72,6 +74,22 @@ impl NodeLibrary { self.set_property("canvasHeight", (height as i64).to_string()); } + /// Returns the canvas background color from properties. + /// Note: internally stored as "canvasBackground" for backwards compatibility. + /// Default is gray (232, 232, 232), matching Java NodeBox. + pub fn background_color(&self) -> Color { + self.properties + .get("canvasBackground") + .and_then(|s| Color::from_hex(s).ok()) + .unwrap_or(Color::rgb(232.0 / 255.0, 232.0 / 255.0, 232.0 / 255.0)) + } + + /// Sets the canvas background color. + /// Note: internally stored as "canvasBackground" for backwards compatibility. + pub fn set_background_color(&mut self, color: Color) { + self.set_property("canvasBackground", color.to_hex()); + } + /// Sets a property. pub fn set_property(&mut self, key: impl Into, value: impl Into) { self.properties.insert(key.into(), value.into()); @@ -123,6 +141,22 @@ impl NodeLibrary { Some(current) } + + /// Returns true if the currently rendered node outputs Point type. + pub fn is_rendered_output_point(&self) -> bool { + self.root.rendered_child.as_ref() + .and_then(|name| self.root.child(name)) + .map(|node| node.output_type == PortType::Point) + .unwrap_or(false) + } + + /// Returns true if the rendered node outputs Geometry or Point (visual types). + pub fn is_rendered_output_geometry(&self) -> bool { + self.root.rendered_child.as_ref() + .and_then(|name| self.root.child(name)) + .map(|node| node.output_type == PortType::Geometry || node.output_type == PortType::Point) + .unwrap_or(false) + } } #[cfg(test)] @@ -172,4 +206,50 @@ mod tests { assert_eq!(lib.width(), 1000.0); assert_eq!(lib.height(), 1000.0); } + + #[test] + fn test_is_rendered_output_point() { + let mut lib = NodeLibrary::new("test"); + + // No rendered child - should return false + assert!(!lib.is_rendered_output_point()); + + // Add a node with default Geometry output type + lib.root = Node::network("root") + .with_child(Node::new("rect1")) + .with_rendered_child("rect1"); + assert!(!lib.is_rendered_output_point()); + + // Add a node with Point output type + lib.root = Node::network("root") + .with_child(Node::new("grid1").with_output_type(PortType::Point)) + .with_rendered_child("grid1"); + assert!(lib.is_rendered_output_point()); + } + + #[test] + fn test_is_rendered_output_geometry() { + let mut lib = NodeLibrary::new("test"); + + // No rendered child - should return false + assert!(!lib.is_rendered_output_geometry()); + + // Add a node with default Geometry output type - should return true + lib.root = Node::network("root") + .with_child(Node::new("rect1")) + .with_rendered_child("rect1"); + assert!(lib.is_rendered_output_geometry()); + + // Add a node with Point output type - should return true + lib.root = Node::network("root") + .with_child(Node::new("grid1").with_output_type(PortType::Point)) + .with_rendered_child("grid1"); + assert!(lib.is_rendered_output_geometry()); + + // Add a node with non-visual output type - should return false + lib.root = Node::network("root") + .with_child(Node::new("add1").with_output_type(PortType::Float)) + .with_rendered_child("add1"); + assert!(!lib.is_rendered_output_geometry()); + } } diff --git a/crates/nodebox-core/src/node/mod.rs b/crates/nodebox-core/src/node/mod.rs index b82d05d5..4011eea3 100644 --- a/crates/nodebox-core/src/node/mod.rs +++ b/crates/nodebox-core/src/node/mod.rs @@ -38,6 +38,12 @@ pub enum EvalError { /// An error occurred in a Python function. PythonError(String), + /// An error occurred during node processing. + ProcessingError(String), + + /// Evaluation was cancelled by the user. + Cancelled, + /// A general evaluation error. Other(String), } @@ -57,6 +63,8 @@ impl std::fmt::Display for EvalError { write!(f, "Cycle detected: {}", nodes.join(" -> ")) } EvalError::PythonError(msg) => write!(f, "Python error: {}", msg), + EvalError::ProcessingError(msg) => write!(f, "{}", msg), + EvalError::Cancelled => write!(f, "Evaluation cancelled"), EvalError::Other(msg) => write!(f, "{}", msg), } } diff --git a/crates/nodebox-core/src/node/node.rs b/crates/nodebox-core/src/node/node.rs index 9a0bcd43..92a5fc33 100644 --- a/crates/nodebox-core/src/node/node.rs +++ b/crates/nodebox-core/src/node/node.rs @@ -142,6 +142,13 @@ impl Node { .find(|c| c.input_node == node_name && c.input_port == port_name) } + /// Returns whether a node participates in any connection (as output or input). + pub fn is_connected(&self, node_name: &str) -> bool { + self.connections + .iter() + .any(|c| c.output_node == node_name || c.input_node == node_name) + } + /// Adds an input port. pub fn with_input(mut self, port: Port) -> Self { self.inputs.push(port); @@ -196,6 +203,14 @@ impl Node { self } + /// Connects an output to an input port, replacing any existing connection to that port. + pub fn connect(&mut self, connection: Connection) { + self.connections.retain(|c| { + !(c.input_node == connection.input_node && c.input_port == connection.input_port) + }); + self.connections.push(connection); + } + /// Sets the rendered child. pub fn with_rendered_child(mut self, name: impl Into) -> Self { self.rendered_child = Some(name.into()); @@ -203,14 +218,11 @@ impl Node { } /// Generates a unique name for a new child based on a prefix. + /// Always appends an index (e.g. "rect1", "rect2") so names can be used as identifiers. pub fn unique_child_name(&self, prefix: &str) -> String { let existing: std::collections::HashSet<_> = self.children.iter().map(|c| c.name.as_str()).collect(); - if !existing.contains(prefix) { - return prefix.to_string(); - } - for i in 1..1000 { let name = format!("{}{}", prefix, i); if !existing.contains(name.as_str()) { @@ -283,13 +295,89 @@ mod tests { assert!(node.connection_to_port("colorize1", "shape").is_some()); } + #[test] + fn test_connect_replaces_existing_connection_to_same_input_port() { + let mut node = Node::network("root") + .with_child(Node::new("rect1")) + .with_child(Node::new("rect2")) + .with_child(Node::new("colorize1")); + + node.connect(Connection::new("rect1", "colorize1", "shape")); + assert_eq!(node.connections.len(), 1); + assert_eq!(node.connections[0].output_node, "rect1"); + + // Connecting a different output to the same input port should replace + node.connect(Connection::new("rect2", "colorize1", "shape")); + assert_eq!(node.connections.len(), 1); + assert_eq!(node.connections[0].output_node, "rect2"); + } + + #[test] + fn test_connect_allows_different_input_ports() { + let mut node = Node::network("root") + .with_child(Node::new("rect1")) + .with_child(Node::new("rect2")) + .with_child(Node::new("colorize1")); + + node.connect(Connection::new("rect1", "colorize1", "shape")); + node.connect(Connection::new("rect2", "colorize1", "fill")); + assert_eq!(node.connections.len(), 2); + } + + #[test] + fn test_is_connected() { + let mut node = Node::network("root") + .with_child(Node::new("number42")) + .with_child(Node::new("number5")) + .with_child(Node::new("add")); + + assert!(!node.is_connected("number42")); + assert!(!node.is_connected("add")); + + node.connect(Connection::new("number42", "add", "v1")); + assert!(node.is_connected("number42")); + assert!(node.is_connected("add")); + + node.connect(Connection::new("number5", "add", "v2")); + assert!(node.is_connected("number5")); + } + + #[test] + fn test_replace_connection_disconnects_old_node() { + let mut node = Node::network("root") + .with_child(Node::new("number42")) + .with_child(Node::new("number5")) + .with_child(Node::new("add")); + + node.connect(Connection::new("number42", "add", "v1")); + assert!(node.is_connected("number42")); + + // Replace the connection to v1 with a different source + node.connect(Connection::new("number5", "add", "v1")); + assert!(!node.is_connected("number42")); + assert!(node.is_connected("number5")); + } + + #[test] + fn test_connect_same_connection_is_idempotent() { + let mut node = Node::network("root") + .with_child(Node::new("rect1")) + .with_child(Node::new("colorize1")); + + node.connect(Connection::new("rect1", "colorize1", "shape")); + node.connect(Connection::new("rect1", "colorize1", "shape")); + assert_eq!(node.connections.len(), 1); + } + #[test] fn test_node_unique_name() { let node = Node::network("root") .with_child(Node::new("rect")) .with_child(Node::new("rect1")); - assert_eq!(node.unique_child_name("ellipse"), "ellipse"); + // Always appends an index, even for new prefixes + assert_eq!(node.unique_child_name("ellipse"), "ellipse1"); + // Skips existing "rect1", returns "rect2" assert_eq!(node.unique_child_name("rect"), "rect2"); } } diff --git a/crates/nodebox-core/src/node/port.rs b/crates/nodebox-core/src/node/port.rs index 10ccd80b..0c78bf54 100644 --- a/crates/nodebox-core/src/node/port.rs +++ b/crates/nodebox-core/src/node/port.rs @@ -13,6 +13,7 @@ pub enum PortType { Point, Color, Geometry, + Data, List, Context, State, @@ -31,6 +32,7 @@ impl PortType { "point" => PortType::Point, "color" => PortType::Color, "geometry" => PortType::Geometry, + "data" => PortType::Data, "list" => PortType::List, "context" => PortType::Context, "state" => PortType::State, @@ -48,6 +50,7 @@ impl PortType { PortType::Point => "point", PortType::Color => "color", PortType::Geometry => "geometry", + PortType::Data => "data", PortType::List => "list", PortType::Context => "context", PortType::State => "state", @@ -65,6 +68,7 @@ impl PortType { PortType::Point => Value::Point(Point::ZERO), PortType::Color => Value::Color(Color::BLACK), PortType::Geometry => Value::Null, + PortType::Data => Value::Null, PortType::List => Value::List(Vec::new()), PortType::Context | PortType::State | PortType::Custom(_) => Value::Null, } @@ -79,6 +83,27 @@ impl PortType { return true; } + // PortType::List is a generic type — list inputs accept any output type + if matches!(input_type, PortType::List) { + return true; + } + + // PortType::List output can connect to any input (runtime type determined by actual data) + if matches!(output_type, PortType::List) { + return true; + } + + // Data output can connect to any input (runtime type determined by actual data, + // e.g. lookup returns Float for numeric columns, String for text, etc.) + if matches!(output_type, PortType::Data) { + return true; + } + + // Data inputs accept any output (data nodes consume lists of data rows) + if matches!(input_type, PortType::Data) { + return true; + } + // Everything can be converted to a string if matches!(input_type, PortType::String) { return true; @@ -338,6 +363,22 @@ impl Port { self.description = Some(description.into()); self } + + /// Sets the menu items and widget type to Menu. + pub fn with_menu_items(mut self, items: Vec) -> Self { + self.widget = Widget::Menu; + self.menu_items = items; + self + } + + /// Creates a menu port with options. + pub fn menu(name: impl Into, default_key: impl Into, items: Vec) -> Self { + let mut port = Port::new(name, PortType::String); + port.value = Value::String(default_key.into()); + port.widget = Widget::Menu; + port.menu_items = items; + port + } } #[cfg(test)] @@ -372,6 +413,21 @@ mod tests { assert!(PortType::is_compatible(&PortType::Int, &PortType::Point)); assert!(PortType::is_compatible(&PortType::Float, &PortType::Point)); + // List input accepts any type (generic list nodes) + assert!(PortType::is_compatible(&PortType::Geometry, &PortType::List)); + assert!(PortType::is_compatible(&PortType::Color, &PortType::List)); + assert!(PortType::is_compatible(&PortType::Point, &PortType::List)); + assert!(PortType::is_compatible(&PortType::Float, &PortType::List)); + assert!(PortType::is_compatible(&PortType::Int, &PortType::List)); + assert!(PortType::is_compatible(&PortType::String, &PortType::List)); + + // List output connects to any type (runtime type determined by data) + assert!(PortType::is_compatible(&PortType::List, &PortType::Geometry)); + assert!(PortType::is_compatible(&PortType::List, &PortType::Color)); + assert!(PortType::is_compatible(&PortType::List, &PortType::Point)); + assert!(PortType::is_compatible(&PortType::List, &PortType::Float)); + assert!(PortType::is_compatible(&PortType::List, &PortType::Int)); + // Incompatible assert!(!PortType::is_compatible(&PortType::String, &PortType::Int)); assert!(!PortType::is_compatible(&PortType::Point, &PortType::Color)); diff --git a/crates/nodebox-core/src/ops/data.rs b/crates/nodebox-core/src/ops/data.rs new file mode 100644 index 00000000..840a5421 --- /dev/null +++ b/crates/nodebox-core/src/ops/data.rs @@ -0,0 +1,464 @@ +//! Data operations for NodeBox. +//! +//! Functions for importing, creating, querying, and filtering tabular data. +//! Data is represented as rows of key-value pairs where values are either +//! strings or floating-point numbers. + +use std::collections::HashMap; + +/// A value within a data row. +/// +/// Matches Java's DataFunctions behavior where CSV values are either +/// String or Double (auto-detected per column). +#[derive(Clone, Debug, PartialEq)] +pub enum DataValue { + String(String), + Float(f64), +} + +impl DataValue { + /// Returns the value as a string representation. + pub fn as_string(&self) -> String { + match self { + DataValue::String(s) => s.clone(), + DataValue::Float(f) => format!("{}", f), + } + } + + /// Tries to extract a float value, parsing from string if needed. + pub fn as_float(&self) -> Option { + match self { + DataValue::Float(f) => Some(*f), + DataValue::String(s) => s.parse::().ok(), + } + } +} + +/// Import CSV content into a list of data rows. +/// +/// - First row is treated as headers. +/// - Empty headers become "Column N". +/// - Per-column numeric detection: if every non-empty value parses as f64, +/// the column is stored as `DataValue::Float`; otherwise `DataValue::String`. +/// - `number_separator`: if `"comma"`, commas in values are replaced with periods +/// before parsing as numbers. +pub fn import_csv( + content: &str, + delimiter: u8, + quote_char: u8, + number_separator: &str, +) -> Vec> { + let mut reader = csv::ReaderBuilder::new() + .delimiter(delimiter) + .quote(quote_char) + .has_headers(true) + .flexible(true) + .from_reader(content.as_bytes()); + + // Read headers + let headers: Vec = match reader.headers() { + Ok(record) => record + .iter() + .enumerate() + .map(|(i, h)| { + let trimmed = h.trim().to_string(); + if trimmed.is_empty() { + format!("Column {}", i + 1) + } else { + trimmed + } + }) + .collect(), + Err(_) => return Vec::new(), + }; + + if headers.is_empty() { + return Vec::new(); + } + + // Read all string records first + let mut raw_rows: Vec> = Vec::new(); + for result in reader.records() { + match result { + Ok(record) => { + let row: Vec = record.iter().map(|s| s.trim().to_string()).collect(); + raw_rows.push(row); + } + Err(_) => continue, + } + } + + // Detect numeric columns: a column is numeric if every non-empty value parses as f64 + let num_cols = headers.len(); + let mut is_numeric = vec![true; num_cols]; + + for row in &raw_rows { + for (col, value) in row.iter().enumerate() { + if col >= num_cols { + break; + } + if value.is_empty() { + continue; + } + let parse_value = if number_separator == "comma" { + value.replace(',', ".") + } else { + value.clone() + }; + if parse_value.parse::().is_err() { + is_numeric[col] = false; + } + } + } + + // Build data rows + let mut result = Vec::with_capacity(raw_rows.len()); + for row in &raw_rows { + let mut data_row = HashMap::new(); + for (col, header) in headers.iter().enumerate() { + let raw = row.get(col).map(|s| s.as_str()).unwrap_or(""); + let value = if is_numeric[col] && !raw.is_empty() { + let parse_value = if number_separator == "comma" { + raw.replace(',', ".") + } else { + raw.to_string() + }; + match parse_value.parse::() { + Ok(f) => DataValue::Float(f), + Err(_) => DataValue::String(raw.to_string()), + } + } else { + DataValue::String(raw.to_string()) + }; + data_row.insert(header.clone(), value); + } + result.push(data_row); + } + + result +} + +/// Build a data table from header names and input lists. +/// +/// - `headers`: column names (split by the caller, e.g. on `;` or `,`) +/// - `lists`: one list per column; only the first `headers.len()` lists are used +/// - Row count = max list length across non-empty lists +/// - Shorter lists pad with `DataValue::String("")` +pub fn make_table( + headers: &[String], + lists: &[Vec], +) -> Vec> { + if headers.is_empty() { + return Vec::new(); + } + + // Only use as many lists as there are headers + let num_cols = headers.len().min(lists.len()); + if num_cols == 0 { + return Vec::new(); + } + + // Find max row count + let max_rows = lists[..num_cols] + .iter() + .map(|l| l.len()) + .max() + .unwrap_or(0); + + if max_rows == 0 { + return Vec::new(); + } + + let mut result = Vec::with_capacity(max_rows); + for row_idx in 0..max_rows { + let mut row = HashMap::new(); + for col_idx in 0..num_cols { + let value = lists[col_idx] + .get(row_idx) + .cloned() + .unwrap_or_else(|| DataValue::String(String::new())); + row.insert(headers[col_idx].clone(), value); + } + result.push(row); + } + + result +} + +/// Look up a value by key in a data row. +pub fn lookup(row: &HashMap, key: &str) -> Option { + row.get(key).cloned() +} + +/// Filter data rows by comparing a key's value against a target. +/// +/// - Tries numeric comparison first (if both the row value and target parse as f64) +/// - Falls back to string comparison for `=` and `!=` +/// - Non-string comparisons (`>`, `<`, etc.) return false for non-numeric values +/// - Rows missing the key are excluded (except for `!=` which includes them) +pub fn filter_data( + rows: &[HashMap], + key: &str, + op: &str, + value: &str, +) -> Vec> { + let target_float = value.parse::().ok(); + + rows.iter() + .filter(|row| { + match row.get(key) { + None => op == "!=", // Missing key: only != matches + Some(data_val) => { + // Try numeric comparison + if let Some(target_f) = target_float { + if let Some(row_f) = data_val.as_float() { + return match op { + "=" => (row_f - target_f).abs() < f64::EPSILON, + "!=" => (row_f - target_f).abs() >= f64::EPSILON, + ">" => row_f > target_f, + ">=" => row_f >= target_f, + "<" => row_f < target_f, + "<=" => row_f <= target_f, + _ => false, + }; + } + } + + // String comparison + let row_str = data_val.as_string(); + match op { + "=" => row_str == value, + "!=" => row_str != value, + // String ordering comparisons + ">" => row_str.as_str() > value, + ">=" => row_str.as_str() >= value, + "<" => (row_str.as_str()) < value, + "<=" => row_str.as_str() <= value, + _ => false, + } + } + } + }) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_import_csv_basic() { + let csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\n"; + let rows = import_csv(csv, b',', b'"', "period"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].get("name"), Some(&DataValue::String("Alice".to_string()))); + assert_eq!(rows[0].get("age"), Some(&DataValue::Float(30.0))); + assert_eq!(rows[0].get("city"), Some(&DataValue::String("NYC".to_string()))); + assert_eq!(rows[1].get("name"), Some(&DataValue::String("Bob".to_string()))); + assert_eq!(rows[1].get("age"), Some(&DataValue::Float(25.0))); + } + + #[test] + fn test_import_csv_semicolon_delimiter() { + let csv = "name;score\nAlice;9.5\nBob;8.0\n"; + let rows = import_csv(csv, b';', b'"', "period"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].get("score"), Some(&DataValue::Float(9.5))); + } + + #[test] + fn test_import_csv_comma_number_separator() { + let csv = "name;price\nWidget;1,50\nGadget;2,75\n"; + let rows = import_csv(csv, b';', b'"', "comma"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].get("price"), Some(&DataValue::Float(1.5))); + assert_eq!(rows[1].get("price"), Some(&DataValue::Float(2.75))); + } + + #[test] + fn test_import_csv_empty_headers() { + let csv = "name,,city\nAlice,30,NYC\n"; + let rows = import_csv(csv, b',', b'"', "period"); + assert_eq!(rows.len(), 1); + assert!(rows[0].contains_key("Column 2")); + } + + #[test] + fn test_import_csv_quoted_fields() { + let csv = "name,description\nAlice,\"Hello, World\"\nBob,\"Line1\"\n"; + let rows = import_csv(csv, b',', b'"', "period"); + assert_eq!(rows.len(), 2); + assert_eq!( + rows[0].get("description"), + Some(&DataValue::String("Hello, World".to_string())) + ); + } + + #[test] + fn test_import_csv_empty_content() { + let rows = import_csv("", b',', b'"', "period"); + assert!(rows.is_empty()); + } + + #[test] + fn test_import_csv_mixed_numeric_column() { + let csv = "name,value\nAlice,10\nBob,N/A\n"; + let rows = import_csv(csv, b',', b'"', "period"); + // "value" column has a non-numeric entry, so all values should be strings + assert_eq!(rows[0].get("value"), Some(&DataValue::String("10".to_string()))); + assert_eq!(rows[1].get("value"), Some(&DataValue::String("N/A".to_string()))); + } + + #[test] + fn test_make_table_basic() { + let headers = vec!["x".to_string(), "y".to_string()]; + let list1 = vec![DataValue::Float(1.0), DataValue::Float(2.0)]; + let list2 = vec![DataValue::Float(10.0), DataValue::Float(20.0)]; + let rows = make_table(&headers, &[list1, list2]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].get("x"), Some(&DataValue::Float(1.0))); + assert_eq!(rows[0].get("y"), Some(&DataValue::Float(10.0))); + assert_eq!(rows[1].get("x"), Some(&DataValue::Float(2.0))); + assert_eq!(rows[1].get("y"), Some(&DataValue::Float(20.0))); + } + + #[test] + fn test_make_table_uneven_lists() { + let headers = vec!["a".to_string(), "b".to_string()]; + let list1 = vec![DataValue::Float(1.0), DataValue::Float(2.0), DataValue::Float(3.0)]; + let list2 = vec![DataValue::String("x".to_string())]; + let rows = make_table(&headers, &[list1, list2]); + assert_eq!(rows.len(), 3); + assert_eq!(rows[2].get("b"), Some(&DataValue::String(String::new()))); + } + + #[test] + fn test_make_table_empty_headers() { + let rows = make_table(&[], &[vec![DataValue::Float(1.0)]]); + assert!(rows.is_empty()); + } + + #[test] + fn test_make_table_more_lists_than_headers() { + let headers = vec!["x".to_string()]; + let list1 = vec![DataValue::Float(1.0)]; + let list2 = vec![DataValue::Float(2.0)]; // Should be ignored + let rows = make_table(&headers, &[list1, list2]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].len(), 1); // Only "x" column + } + + #[test] + fn test_lookup_existing_key() { + let mut row = HashMap::new(); + row.insert("name".to_string(), DataValue::String("Alice".to_string())); + row.insert("age".to_string(), DataValue::Float(30.0)); + assert_eq!(lookup(&row, "name"), Some(DataValue::String("Alice".to_string()))); + assert_eq!(lookup(&row, "age"), Some(DataValue::Float(30.0))); + } + + #[test] + fn test_lookup_missing_key() { + let row = HashMap::new(); + assert_eq!(lookup(&row, "missing"), None); + } + + #[test] + fn test_filter_data_equals() { + let rows = vec![ + { + let mut r = HashMap::new(); + r.insert("name".to_string(), DataValue::String("Alice".to_string())); + r.insert("age".to_string(), DataValue::Float(30.0)); + r + }, + { + let mut r = HashMap::new(); + r.insert("name".to_string(), DataValue::String("Bob".to_string())); + r.insert("age".to_string(), DataValue::Float(25.0)); + r + }, + ]; + + let result = filter_data(&rows, "name", "=", "Alice"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].get("name"), Some(&DataValue::String("Alice".to_string()))); + } + + #[test] + fn test_filter_data_numeric_greater() { + let rows = vec![ + { + let mut r = HashMap::new(); + r.insert("score".to_string(), DataValue::Float(90.0)); + r + }, + { + let mut r = HashMap::new(); + r.insert("score".to_string(), DataValue::Float(70.0)); + r + }, + { + let mut r = HashMap::new(); + r.insert("score".to_string(), DataValue::Float(85.0)); + r + }, + ]; + + let result = filter_data(&rows, "score", ">", "80"); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_filter_data_not_equals() { + let rows = vec![ + { + let mut r = HashMap::new(); + r.insert("status".to_string(), DataValue::String("active".to_string())); + r + }, + { + let mut r = HashMap::new(); + r.insert("status".to_string(), DataValue::String("inactive".to_string())); + r + }, + ]; + + let result = filter_data(&rows, "status", "!=", "active"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].get("status"), Some(&DataValue::String("inactive".to_string()))); + } + + #[test] + fn test_filter_data_missing_key() { + let rows = vec![ + { + let mut r = HashMap::new(); + r.insert("name".to_string(), DataValue::String("Alice".to_string())); + r + }, + ]; + + // Missing key with != should include the row + let result = filter_data(&rows, "missing", "!=", "anything"); + assert_eq!(result.len(), 1); + + // Missing key with = should exclude the row + let result = filter_data(&rows, "missing", "=", "anything"); + assert_eq!(result.len(), 0); + } + + #[test] + fn test_data_value_as_string() { + assert_eq!(DataValue::String("hello".into()).as_string(), "hello"); + assert_eq!(DataValue::Float(3.14).as_string(), "3.14"); + } + + #[test] + fn test_data_value_as_float() { + assert_eq!(DataValue::Float(3.14).as_float(), Some(3.14)); + assert_eq!(DataValue::String("2.5".into()).as_float(), Some(2.5)); + assert_eq!(DataValue::String("hello".into()).as_float(), None); + } +} diff --git a/crates/nodebox-ops/src/filters.rs b/crates/nodebox-core/src/ops/filters.rs similarity index 77% rename from crates/nodebox-ops/src/filters.rs rename to crates/nodebox-core/src/ops/filters.rs index 1bf12eac..a924d5e2 100644 --- a/crates/nodebox-ops/src/filters.rs +++ b/crates/nodebox-core/src/ops/filters.rs @@ -1,6 +1,6 @@ //! Geometry filters - functions that transform existing shapes. -use nodebox_core::geometry::{Point, Path, Geometry, Color, Transform}; +use crate::geometry::{Point, Path, Geometry, Color, Transform, Rect}; /// Horizontal alignment options. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -52,6 +52,56 @@ impl VAlign { } } +/// Horizontal distribution mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HDistribute { + /// No horizontal distribution. + None, + /// Distribute by left edge. + Left, + /// Distribute by center. + Center, + /// Distribute by right edge. + Right, +} + +impl HDistribute { + /// Parse from string. + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "left" => HDistribute::Left, + "center" => HDistribute::Center, + "right" => HDistribute::Right, + _ => HDistribute::None, + } + } +} + +/// Vertical distribution mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VDistribute { + /// No vertical distribution. + None, + /// Distribute by top edge. + Top, + /// Distribute by middle. + Middle, + /// Distribute by bottom edge. + Bottom, +} + +impl VDistribute { + /// Parse from string. + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "top" => VDistribute::Top, + "middle" => VDistribute::Middle, + "bottom" => VDistribute::Bottom, + _ => VDistribute::None, + } + } +} + /// Align geometry in relation to a position. /// /// # Arguments @@ -63,7 +113,7 @@ impl VAlign { /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::{align, HAlign, VAlign}; +/// use nodebox_core::ops::{align, HAlign, VAlign}; /// /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); /// let aligned = align(&path, Point::ZERO, HAlign::Center, VAlign::Middle); @@ -114,7 +164,7 @@ pub fn align_str(geometry: &Path, position: Point, halign: &str, valign: &str) - /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::centroid; +/// use nodebox_core::ops::centroid; /// /// // Path::rect uses top-left corner, so center is at (50, 50) /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); @@ -143,7 +193,7 @@ pub fn centroid(geometry: &Path) -> Point { /// # Example /// ``` /// use nodebox_core::{Path, Color}; -/// use nodebox_ops::colorize; +/// use nodebox_core::ops::colorize; /// /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); /// let red = Color::rgb(1.0, 0.0, 0.0); @@ -179,7 +229,7 @@ pub fn colorize(path: &Path, fill: Color, stroke: Color, stroke_width: f64) -> P /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::fit; +/// use nodebox_core::ops::fit; /// /// let path = Path::rect(0.0, 0.0, 100.0, 200.0); /// let fitted = fit(&path, Point::ZERO, 50.0, 50.0, true); @@ -277,7 +327,7 @@ impl CopyOrder { /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::{copy, CopyOrder}; +/// use nodebox_core::ops::{copy, CopyOrder}; /// /// let path = Path::rect(0.0, 0.0, 10.0, 10.0); /// let copies = copy(&path, 5, CopyOrder::TSR, Point::new(20.0, 0.0), 0.0, Point::new(100.0, 100.0)); @@ -345,7 +395,7 @@ fn build_copy_transform(order: CopyOrder, tx: f64, ty: f64, r: f64, sx: f64, sy: /// # Example /// ``` /// use nodebox_core::{Path, Geometry}; -/// use nodebox_ops::group; +/// use nodebox_core::ops::group; /// /// let rect = Path::rect(0.0, 0.0, 100.0, 100.0); /// let ellipse = Path::ellipse(0.0, 0.0, 50.0, 50.0); @@ -368,7 +418,7 @@ pub fn group(paths: &[Path]) -> Geometry { /// # Example /// ``` /// use nodebox_core::{Path, Geometry}; -/// use nodebox_ops::{group, ungroup}; +/// use nodebox_core::ops::{group, ungroup}; /// /// let rect = Path::rect(0.0, 0.0, 100.0, 100.0); /// let ellipse = Path::ellipse(0.0, 0.0, 50.0, 50.0); @@ -388,7 +438,7 @@ pub fn ungroup(geometry: &Geometry) -> Vec { /// # Example /// ``` /// use nodebox_core::Path; -/// use nodebox_ops::to_points; +/// use nodebox_core::ops::to_points; /// /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); /// let points = to_points(&path); @@ -411,15 +461,15 @@ pub fn to_points(path: &Path) -> Vec { /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::rotate; +/// use nodebox_core::ops::rotate; /// /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); /// let rotated = rotate(&path, 45.0, Point::ZERO); /// ``` pub fn rotate(geometry: &Path, angle: f64, origin: Point) -> Path { - let t = Transform::translate(origin.x, origin.y) + let t = Transform::translate(-origin.x, -origin.y) .then(&Transform::rotate(angle)) - .then(&Transform::translate(-origin.x, -origin.y)); + .then(&Transform::translate(origin.x, origin.y)); geometry.transform(&t) } @@ -433,7 +483,7 @@ pub fn rotate(geometry: &Path, angle: f64, origin: Point) -> Path { /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::scale; +/// use nodebox_core::ops::scale; /// /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); /// let scaled = scale(&path, Point::new(200.0, 200.0), Point::ZERO); @@ -441,9 +491,9 @@ pub fn rotate(geometry: &Path, angle: f64, origin: Point) -> Path { pub fn scale(geometry: &Path, scale_pct: Point, origin: Point) -> Path { let sx = scale_pct.x / 100.0; let sy = scale_pct.y / 100.0; - let t = Transform::translate(origin.x, origin.y) + let t = Transform::translate(-origin.x, -origin.y) .then(&Transform::scale_xy(sx, sy)) - .then(&Transform::translate(-origin.x, -origin.y)); + .then(&Transform::translate(origin.x, origin.y)); geometry.transform(&t) } @@ -456,7 +506,7 @@ pub fn scale(geometry: &Path, scale_pct: Point, origin: Point) -> Path { /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::translate; +/// use nodebox_core::ops::translate; /// /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); /// let moved = translate(&path, Point::new(50.0, 50.0)); @@ -476,15 +526,15 @@ pub fn translate(geometry: &Path, offset: Point) -> Path { /// # Example /// ``` /// use nodebox_core::{Point, Path}; -/// use nodebox_ops::skew; +/// use nodebox_core::ops::skew; /// /// let path = Path::rect(0.0, 0.0, 100.0, 100.0); /// let skewed = skew(&path, Point::new(10.0, 0.0), Point::ZERO); /// ``` pub fn skew(geometry: &Path, skew_angle: Point, origin: Point) -> Path { - let t = Transform::translate(origin.x, origin.y) + let t = Transform::translate(-origin.x, -origin.y) .then(&Transform::skew(skew_angle.x, skew_angle.y)) - .then(&Transform::translate(-origin.x, -origin.y)); + .then(&Transform::translate(origin.x, origin.y)); geometry.transform(&t) } @@ -508,7 +558,7 @@ pub fn do_nothing(path: &Path) -> Path { /// # Example /// ``` /// use nodebox_core::Path; -/// use nodebox_ops::point_on_path; +/// use nodebox_core::ops::point_on_path; /// /// let path = Path::line(0.0, 0.0, 100.0, 0.0); /// let mid = point_on_path(&path, 0.5); @@ -526,7 +576,7 @@ pub fn point_on_path(path: &Path, t: f64) -> Point { /// # Example /// ``` /// use nodebox_core::Path; -/// use nodebox_ops::path_length; +/// use nodebox_core::ops::path_length; /// /// let path = Path::line(0.0, 0.0, 100.0, 0.0); /// assert!((path_length(&path) - 100.0).abs() < 0.001); @@ -544,7 +594,7 @@ pub fn path_length(path: &Path) -> f64 { /// # Example /// ``` /// use nodebox_core::Path; -/// use nodebox_ops::make_points; +/// use nodebox_core::ops::make_points; /// /// let path = Path::line(0.0, 0.0, 100.0, 0.0); /// let points = make_points(&path, 5); @@ -566,7 +616,7 @@ pub fn make_points(path: &Path, amount: usize) -> Vec { /// # Example /// ``` /// use nodebox_core::Path; -/// use nodebox_ops::resample; +/// use nodebox_core::ops::resample; /// /// let path = Path::ellipse(0.0, 0.0, 100.0, 100.0); /// let resampled = resample(&path, 20); @@ -587,7 +637,7 @@ pub fn resample(path: &Path, amount: usize) -> Path { /// # Example /// ``` /// use nodebox_core::Path; -/// use nodebox_ops::resample_by_length; +/// use nodebox_core::ops::resample_by_length; /// /// let path = Path::line(0.0, 0.0, 100.0, 0.0); /// let resampled = resample_by_length(&path, 25.0); @@ -960,6 +1010,146 @@ pub fn stack(paths: &[Path], direction: StackDirection, margin: f64) -> Vec f64 { + match axis { + DistributeAxis::Left => bounds.x, + DistributeAxis::Center => bounds.x + bounds.width / 2.0, + DistributeAxis::Right => bounds.x + bounds.width, + DistributeAxis::Top => bounds.y, + DistributeAxis::Middle => bounds.y + bounds.height / 2.0, + DistributeAxis::Bottom => bounds.y + bounds.height, + } +} + +/// Distribute shapes along a single axis. +/// +/// Sorts shapes by `main_axis`, finds the two extrema (smallest ext1 and +/// largest ext2), locks them in place, and evenly spaces all other shapes +/// between them. +fn distribute_axis(paths: &[Path], main_axis: DistributeAxis) -> Vec { + let n = paths.len(); + + // Determine ext1/ext2 axes and whether this is horizontal + let (ext1, ext2, horizontal) = match main_axis { + DistributeAxis::Left | DistributeAxis::Center | DistributeAxis::Right => { + (DistributeAxis::Left, DistributeAxis::Right, true) + } + DistributeAxis::Top | DistributeAxis::Middle | DistributeAxis::Bottom => { + (DistributeAxis::Top, DistributeAxis::Bottom, false) + } + }; + + // Compute bounds for every shape + let bounds: Vec = paths + .iter() + .map(|p| p.bounds().unwrap_or_default()) + .collect(); + + // Sort indices by main_axis metric + let mut sorted_indices: Vec = (0..n).collect(); + sorted_indices.sort_by(|&a, &b| { + distribute_metric(&bounds[a], main_axis) + .partial_cmp(&distribute_metric(&bounds[b], main_axis)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + // Find extremum1: shape with the smallest ext1 value + let extremum1_idx = (0..n) + .min_by(|&a, &b| { + distribute_metric(&bounds[a], ext1) + .partial_cmp(&distribute_metric(&bounds[b], ext1)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap(); + + // Find extremum2: shape with the largest ext2 value + let extremum2_idx = (0..n) + .max_by(|&a, &b| { + distribute_metric(&bounds[a], ext2) + .partial_cmp(&distribute_metric(&bounds[b], ext2)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap(); + + let outer1 = distribute_metric(&bounds[extremum1_idx], main_axis); + let outer2 = distribute_metric(&bounds[extremum2_idx], main_axis); + let skip = (outer2 - outer1) / (n as f64 - 1.0); + + // Build a map: original index -> sorted position + let mut sorted_position: Vec = vec![0; n]; + for (pos, &orig_idx) in sorted_indices.iter().enumerate() { + sorted_position[orig_idx] = pos; + } + + let i_e1 = sorted_position[extremum1_idx]; + let i_e2 = sorted_position[extremum2_idx]; + + // Build result preserving original order + let mut result = Vec::with_capacity(n); + for (idx, path) in paths.iter().enumerate() { + if idx == extremum1_idx || idx == extremum2_idx { + result.push(path.clone()); + } else { + let mut i = sorted_position[idx]; + if i < i_e1 { + i += 1; + } + if i > i_e2 { + i -= 1; + } + + let target = outer1 + (i as f64) * skip; + let current = distribute_metric(&bounds[idx], main_axis); + let delta = target - current; + + let offset = if horizontal { + Point::new(delta, 0.0) + } else { + Point::new(0.0, delta) + }; + result.push(translate(path, offset)); + } + } + + result +} + +/// Distribute shapes on horizontal and/or vertical axes. +/// +/// Evenly spaces shapes between the two outermost shapes. Requires at least +/// 3 shapes for distribution to take effect. +pub fn distribute(paths: &[Path], horizontal: HDistribute, vertical: VDistribute) -> Vec { + if paths.len() < 3 || (horizontal == HDistribute::None && vertical == VDistribute::None) { + return paths.to_vec(); + } + + let result = match horizontal { + HDistribute::None => paths.to_vec(), + HDistribute::Left => distribute_axis(paths, DistributeAxis::Left), + HDistribute::Center => distribute_axis(paths, DistributeAxis::Center), + HDistribute::Right => distribute_axis(paths, DistributeAxis::Right), + }; + + match vertical { + VDistribute::None => result, + VDistribute::Top => distribute_axis(&result, DistributeAxis::Top), + VDistribute::Middle => distribute_axis(&result, DistributeAxis::Middle), + VDistribute::Bottom => distribute_axis(&result, DistributeAxis::Bottom), + } +} + /// Place shapes along a path. /// /// # Arguments @@ -1498,4 +1688,153 @@ mod tests { let placed = shape_on_path(&[shape], &guide, 3, 10.0, 0.0, false); assert_eq!(placed.len(), 3); } + + // ======================================================================== + // Distribute Tests + // ======================================================================== + + #[test] + fn test_distribute_empty() { + let result = distribute(&[], HDistribute::Center, VDistribute::None); + assert!(result.is_empty()); + } + + #[test] + fn test_distribute_fewer_than_3() { + let shapes = vec![ + Path::rect(0.0, 0.0, 20.0, 20.0), + Path::rect(100.0, 0.0, 20.0, 20.0), + ]; + let result = distribute(&shapes, HDistribute::Center, VDistribute::None); + assert_eq!(result.len(), 2); + let b0 = result[0].bounds().unwrap(); + let b1 = result[1].bounds().unwrap(); + assert_relative_eq!(b0.x, 0.0, epsilon = 0.01); + assert_relative_eq!(b1.x, 100.0, epsilon = 0.01); + } + + #[test] + fn test_distribute_none_modes() { + let shapes = vec![ + Path::rect(0.0, 0.0, 20.0, 20.0), + Path::rect(50.0, 0.0, 30.0, 30.0), + Path::rect(120.0, 0.0, 10.0, 10.0), + ]; + let result = distribute(&shapes, HDistribute::None, VDistribute::None); + assert_eq!(result.len(), 3); + for (orig, res) in shapes.iter().zip(result.iter()) { + let ob = orig.bounds().unwrap(); + let rb = res.bounds().unwrap(); + assert_relative_eq!(ob.x, rb.x, epsilon = 0.01); + assert_relative_eq!(ob.y, rb.y, epsilon = 0.01); + } + } + + #[test] + fn test_distribute_horizontal_center() { + // Three shapes at different x positions + let shapes = vec![ + Path::rect(0.0, 0.0, 20.0, 20.0), // center_x = 10, left=0, right=20 + Path::rect(80.0, 0.0, 20.0, 20.0), // center_x = 90, left=80, right=100 + Path::rect(10.0, 0.0, 20.0, 20.0), // center_x = 20, left=10, right=30 + ]; + let result = distribute(&shapes, HDistribute::Center, VDistribute::None); + assert_eq!(result.len(), 3); + + // Extremum1 (smallest left=0): shape 0 + // Extremum2 (largest right=100): shape 1 + // outer1 = center of shape 0 = 10 + // outer2 = center of shape 1 = 90 + // skip = (90 - 10) / 2 = 40 + // Shape 2 should get center_x = 10 + 1*40 = 50 + let centers: Vec = result + .iter() + .map(|p| { + let b = p.bounds().unwrap(); + b.x + b.width / 2.0 + }) + .collect(); + + assert_relative_eq!(centers[0], 10.0, epsilon = 0.1); + assert_relative_eq!(centers[1], 90.0, epsilon = 0.1); + assert_relative_eq!(centers[2], 50.0, epsilon = 0.1); + } + + #[test] + fn test_distribute_vertical_middle() { + let shapes = vec![ + Path::rect(0.0, 0.0, 20.0, 20.0), // middle_y = 10 + Path::rect(0.0, 80.0, 20.0, 20.0), // middle_y = 90 + Path::rect(0.0, 15.0, 20.0, 20.0), // middle_y = 25 + ]; + let result = distribute(&shapes, HDistribute::None, VDistribute::Middle); + assert_eq!(result.len(), 3); + + let middles: Vec = result + .iter() + .map(|p| { + let b = p.bounds().unwrap(); + b.y + b.height / 2.0 + }) + .collect(); + + assert_relative_eq!(middles[0], 10.0, epsilon = 0.1); + assert_relative_eq!(middles[1], 90.0, epsilon = 0.1); + assert_relative_eq!(middles[2], 50.0, epsilon = 0.1); + } + + #[test] + fn test_distribute_both_axes() { + let shapes = vec![ + Path::rect(0.0, 0.0, 20.0, 20.0), + Path::rect(80.0, 80.0, 20.0, 20.0), + Path::rect(10.0, 10.0, 20.0, 20.0), + ]; + let result = distribute(&shapes, HDistribute::Center, VDistribute::Middle); + assert_eq!(result.len(), 3); + + // Both axes should be distributed + let centers_x: Vec = result + .iter() + .map(|p| { + let b = p.bounds().unwrap(); + b.x + b.width / 2.0 + }) + .collect(); + let centers_y: Vec = result + .iter() + .map(|p| { + let b = p.bounds().unwrap(); + b.y + b.height / 2.0 + }) + .collect(); + + // Extrema stay in place + assert_relative_eq!(centers_x[0], 10.0, epsilon = 0.1); + assert_relative_eq!(centers_x[1], 90.0, epsilon = 0.1); + assert_relative_eq!(centers_y[0], 10.0, epsilon = 0.1); + assert_relative_eq!(centers_y[1], 90.0, epsilon = 0.1); + // Middle shape evenly spaced + assert_relative_eq!(centers_x[2], 50.0, epsilon = 0.1); + assert_relative_eq!(centers_y[2], 50.0, epsilon = 0.1); + } + + #[test] + fn test_hdistribute_from_str() { + assert_eq!(HDistribute::from_str("left"), HDistribute::Left); + assert_eq!(HDistribute::from_str("center"), HDistribute::Center); + assert_eq!(HDistribute::from_str("right"), HDistribute::Right); + assert_eq!(HDistribute::from_str("none"), HDistribute::None); + assert_eq!(HDistribute::from_str("unknown"), HDistribute::None); + assert_eq!(HDistribute::from_str("LEFT"), HDistribute::Left); + } + + #[test] + fn test_vdistribute_from_str() { + assert_eq!(VDistribute::from_str("top"), VDistribute::Top); + assert_eq!(VDistribute::from_str("middle"), VDistribute::Middle); + assert_eq!(VDistribute::from_str("bottom"), VDistribute::Bottom); + assert_eq!(VDistribute::from_str("none"), VDistribute::None); + assert_eq!(VDistribute::from_str("MIDDLE"), VDistribute::Middle); + } } diff --git a/crates/nodebox-ops/src/generators.rs b/crates/nodebox-core/src/ops/generators.rs similarity index 96% rename from crates/nodebox-ops/src/generators.rs rename to crates/nodebox-core/src/ops/generators.rs index c5441987..4a922ce6 100644 --- a/crates/nodebox-ops/src/generators.rs +++ b/crates/nodebox-core/src/ops/generators.rs @@ -1,7 +1,7 @@ //! Geometry generators - functions that create new shapes. use std::f64::consts::PI; -use nodebox_core::geometry::{Point, Path, Color, Contour}; +use crate::geometry::{Point, Path, Color, Contour}; /// Create an ellipse at the given position. /// @@ -13,7 +13,7 @@ use nodebox_core::geometry::{Point, Path, Color, Contour}; /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::ellipse; +/// use nodebox_core::ops::ellipse; /// /// let path = ellipse(Point::ZERO, 100.0, 50.0); /// assert!(!path.contours.is_empty()); @@ -33,7 +33,7 @@ pub fn ellipse(position: Point, width: f64, height: f64) -> Path { /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::rect; +/// use nodebox_core::ops::rect; /// /// let path = rect(Point::ZERO, 100.0, 100.0, Point::ZERO); /// assert!(!path.contours.is_empty()); @@ -121,7 +121,7 @@ fn rounded_rect(position: Point, width: f64, height: f64, rx: f64, ry: f64) -> P /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::line; +/// use nodebox_core::ops::line; /// /// let path = line(Point::ZERO, Point::new(100.0, 100.0), 2); /// assert_eq!(path.contours[0].points.len(), 2); @@ -164,7 +164,7 @@ pub fn line(p1: Point, p2: Point, points: u32) -> Path { /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::line_angle; +/// use nodebox_core::ops::line_angle; /// /// let path = line_angle(Point::ZERO, 45.0, 100.0, 2); /// assert!(!path.contours.is_empty()); @@ -201,7 +201,7 @@ pub fn coordinates(point: Point, angle: f64, distance: f64) -> Point { /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::arc; +/// use nodebox_core::ops::arc; /// /// let path = arc(Point::ZERO, 100.0, 100.0, 0.0, 90.0, "pie"); /// assert!(!path.contours.is_empty()); @@ -210,9 +210,9 @@ pub fn arc(position: Point, width: f64, height: f64, start_angle: f64, degrees: let rx = width / 2.0; let ry = height / 2.0; - // Convert angles to radians (negated for compatibility with Java's Arc2D) - let start_rad = -start_angle * PI / 180.0; - let _end_rad = start_rad - degrees * PI / 180.0; + // Convert angles to radians + let start_rad = start_angle * PI / 180.0; + let _end_rad = start_rad + degrees * PI / 180.0; let mut contour = Contour::new(); @@ -234,8 +234,8 @@ pub fn arc(position: Point, width: f64, height: f64, start_angle: f64, degrees: let segment_angle = degrees / segments as f64; for i in 0..segments { - let a1 = start_rad - (i as f64 * segment_angle) * PI / 180.0; - let a2 = start_rad - ((i + 1) as f64 * segment_angle) * PI / 180.0; + let a1 = start_rad + (i as f64 * segment_angle) * PI / 180.0; + let a2 = start_rad + ((i + 1) as f64 * segment_angle) * PI / 180.0; arc_bezier_segment(&mut contour, position, rx, ry, a1, a2); } @@ -283,7 +283,7 @@ fn arc_bezier_segment(contour: &mut Contour, center: Point, rx: f64, ry: f64, a1 /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::polygon; +/// use nodebox_core::ops::polygon; /// /// let path = polygon(Point::ZERO, 50.0, 6, false); /// // Hexagon has 7 points (6 + 1 to close) @@ -329,7 +329,7 @@ pub fn polygon(position: Point, radius: f64, sides: u32, align: bool) -> Path { /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::star; +/// use nodebox_core::ops::star; /// /// let path = star(Point::ZERO, 5, 50.0, 25.0); /// // 5-pointed star has 11 points (2*5 + 1 to close) @@ -371,7 +371,7 @@ pub fn star(position: Point, points: u32, outer: f64, inner: f64) -> Path { /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::grid; +/// use nodebox_core::ops::grid; /// /// let points = grid(3, 3, 100.0, 100.0, Point::ZERO); /// assert_eq!(points.len(), 9); @@ -414,7 +414,7 @@ pub fn grid(columns: u32, rows: u32, width: f64, height: f64, position: Point) - /// # Example /// ``` /// use nodebox_core::Point; -/// use nodebox_ops::connect; +/// use nodebox_core::ops::connect; /// /// let points = vec![Point::ZERO, Point::new(100.0, 0.0), Point::new(50.0, 100.0)]; /// let path = connect(&points, true); @@ -451,7 +451,7 @@ pub fn connect(points: &[Point], closed: bool) -> Path { /// /// # Example /// ``` -/// use nodebox_ops::make_point; +/// use nodebox_core::ops::make_point; /// /// let p = make_point(10.0, 20.0); /// assert_eq!(p.x, 10.0); @@ -465,7 +465,7 @@ pub fn make_point(x: f64, y: f64) -> Point { mod tests { use super::*; use approx::assert_relative_eq; - use nodebox_core::PointType; + use crate::PointType; #[test] fn test_ellipse() { diff --git a/crates/nodebox-ops/src/list.rs b/crates/nodebox-core/src/ops/list.rs similarity index 100% rename from crates/nodebox-ops/src/list.rs rename to crates/nodebox-core/src/ops/list.rs diff --git a/crates/nodebox-ops/src/math.rs b/crates/nodebox-core/src/ops/math.rs similarity index 99% rename from crates/nodebox-ops/src/math.rs rename to crates/nodebox-core/src/ops/math.rs index 755f92a1..177f1c34 100644 --- a/crates/nodebox-ops/src/math.rs +++ b/crates/nodebox-core/src/ops/math.rs @@ -3,7 +3,7 @@ //! This module provides mathematical functions including basic arithmetic, //! trigonometry, aggregation, and number generation. -use nodebox_core::geometry::Point; +use crate::geometry::Point; use std::f64::consts::{E, PI}; /// Identity function for floating point numbers. diff --git a/crates/nodebox-ops/src/lib.rs b/crates/nodebox-core/src/ops/mod.rs similarity index 82% rename from crates/nodebox-ops/src/lib.rs rename to crates/nodebox-core/src/ops/mod.rs index 4e5a2d0b..d3c60a49 100644 --- a/crates/nodebox-ops/src/lib.rs +++ b/crates/nodebox-core/src/ops/mod.rs @@ -1,6 +1,6 @@ //! Operations for NodeBox. //! -//! This crate provides functions for generating and manipulating geometry, +//! Functions for generating and manipulating geometry, //! as well as math, list, and string operations. //! //! # Modules @@ -11,13 +11,17 @@ //! - [`list`] - List manipulation operations (sort, filter, combine, etc.) //! - [`string`] - String manipulation operations (case, split, format, etc.) //! - [`parallel`] - Parallel versions of operations using Rayon +//! - [`svg`] - SVG import functionality pub mod generators; pub mod filters; pub mod math; pub mod list; pub mod string; +pub mod data; pub mod parallel; +pub mod svg; pub use generators::*; pub use filters::*; +pub use svg::import_svg; diff --git a/crates/nodebox-ops/src/parallel.rs b/crates/nodebox-core/src/ops/parallel.rs similarity index 96% rename from crates/nodebox-ops/src/parallel.rs rename to crates/nodebox-core/src/ops/parallel.rs index 84204372..1df7e16c 100644 --- a/crates/nodebox-ops/src/parallel.rs +++ b/crates/nodebox-core/src/ops/parallel.rs @@ -7,7 +7,7 @@ //! //! ```rust //! use nodebox_core::geometry::{Path, Point}; -//! use nodebox_ops::parallel; +//! use nodebox_core::ops::parallel; //! //! let paths: Vec = (0..100) //! .map(|i| Path::rect(i as f64 * 10.0, 0.0, 10.0, 10.0)) @@ -16,7 +16,7 @@ //! let translated = parallel::translate_all(&paths, Point::new(100.0, 100.0)); //! ``` -use nodebox_core::geometry::{Color, Path, Point, Transform}; +use crate::geometry::{Color, Path, Point, Transform}; use rayon::prelude::*; /// Translate multiple paths in parallel. @@ -27,9 +27,9 @@ pub fn translate_all(paths: &[Path], offset: Point) -> Vec { /// Rotate multiple paths in parallel. pub fn rotate_all(paths: &[Path], angle: f64, origin: Point) -> Vec { - let transform = Transform::translate(origin.x, origin.y) + let transform = Transform::translate(-origin.x, -origin.y) .then(&Transform::rotate(angle)) - .then(&Transform::translate(-origin.x, -origin.y)); + .then(&Transform::translate(origin.x, origin.y)); paths.par_iter().map(|p| p.transform(&transform)).collect() } @@ -37,9 +37,9 @@ pub fn rotate_all(paths: &[Path], angle: f64, origin: Point) -> Vec { pub fn scale_all(paths: &[Path], scale_pct: Point, origin: Point) -> Vec { let sx = scale_pct.x / 100.0; let sy = scale_pct.y / 100.0; - let transform = Transform::translate(origin.x, origin.y) + let transform = Transform::translate(-origin.x, -origin.y) .then(&Transform::scale_xy(sx, sy)) - .then(&Transform::translate(-origin.x, -origin.y)); + .then(&Transform::translate(origin.x, origin.y)); paths.par_iter().map(|p| p.transform(&transform)).collect() } diff --git a/crates/nodebox-ops/src/string.rs b/crates/nodebox-core/src/ops/string.rs similarity index 100% rename from crates/nodebox-ops/src/string.rs rename to crates/nodebox-core/src/ops/string.rs diff --git a/crates/nodebox-core/src/ops/svg.rs b/crates/nodebox-core/src/ops/svg.rs new file mode 100644 index 00000000..50ed283c --- /dev/null +++ b/crates/nodebox-core/src/ops/svg.rs @@ -0,0 +1,417 @@ +//! SVG import functionality. +//! +//! This module provides functions to import SVG content and convert it to NodeBox geometry. +//! +//! File reading is NOT done by this module. Callers are responsible for reading +//! SVG files through the Port system (for sandboxed file access) and passing +//! the string content here. + +use crate::geometry::{Color, Contour, Geometry, Path, Point, Transform}; +use usvg::{tiny_skia_path::PathSegment, Tree}; + +/// Import SVG from string content and convert it to NodeBox Geometry. +/// +/// This is the ONLY version - no file path version exists. +/// Callers are responsible for reading the file through the Port system. +/// +/// # Arguments +/// * `svg_content` - SVG content as a string +/// * `centered` - If true, center the geometry at the origin before applying position +/// * `position` - Position offset to apply after optional centering +/// +/// # Returns +/// * `Ok(Geometry)` - The imported geometry +/// * `Err(String)` - Error message if import fails +/// +/// # Examples +/// +/// ```ignore +/// use nodebox_core::geometry::Point; +/// use nodebox_core::ops::import_svg; +/// +/// let svg_content = r#""#; +/// let geometry = import_svg(svg_content, true, Point::ZERO)?; +/// ``` +pub fn import_svg(svg_content: &str, centered: bool, position: Point) -> Result { + // Empty content returns empty geometry + if svg_content.is_empty() { + return Ok(Geometry::new()); + } + + // Parse SVG using usvg with default options + let options = usvg::Options::default(); + let tree = Tree::from_data(svg_content.as_bytes(), &options) + .map_err(|e| format!("Failed to parse SVG: {}", e))?; + + // Convert the usvg tree to NodeBox geometry + let mut geometry = convert_tree_to_geometry(&tree); + + // Apply centering if requested + if centered { + if let Some(bounds) = geometry.bounds() { + let center = bounds.center(); + let centering_transform = Transform::translate(-center.x, -center.y); + geometry = geometry.transform(¢ering_transform); + } + } + + // Apply position offset + if position.x != 0.0 || position.y != 0.0 { + let position_transform = Transform::translate(position.x, position.y); + geometry = geometry.transform(&position_transform); + } + + Ok(geometry) +} + +/// Convert a usvg Tree to NodeBox Geometry. +fn convert_tree_to_geometry(tree: &Tree) -> Geometry { + let mut geometry = Geometry::new(); + + // Process all nodes in the tree + for node in tree.root().children() { + process_node(&node, Transform::IDENTITY, &mut geometry); + } + + geometry +} + +/// Recursively process a usvg node. +fn process_node(node: &usvg::Node, parent_transform: Transform, geometry: &mut Geometry) { + match node { + usvg::Node::Group(group) => { + // Combine parent transform with group transform + let group_transform = usvg_transform_to_nodebox(&group.transform()); + let combined_transform = parent_transform.then(&group_transform); + + // Process children + for child in group.children() { + process_node(&child, combined_transform, geometry); + } + } + usvg::Node::Path(path) => { + if let Some(nodebox_path) = convert_path(path, &parent_transform) { + geometry.add(nodebox_path); + } + } + usvg::Node::Image(_) => { + // Images are not supported, skip + } + usvg::Node::Text(_) => { + // Text is not supported, skip (as per requirements) + } + } +} + +/// Convert a usvg Transform to a NodeBox Transform. +fn usvg_transform_to_nodebox(t: &usvg::Transform) -> Transform { + Transform::new(t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64) +} + +/// Convert a usvg Path to a NodeBox Path. +fn convert_path(usvg_path: &usvg::Path, transform: &Transform) -> Option { + let data = usvg_path.data(); + + // Create contours from path data + let contours = convert_path_data(data); + + if contours.is_empty() { + return None; + } + + // Create the NodeBox path + let mut path = Path::from_contours(contours); + + // Apply fill + if let Some(fill) = usvg_path.fill() { + path.fill = usvg_paint_to_color(&fill.paint(), fill.opacity().get()); + } else { + path.fill = None; + } + + // Apply stroke + if let Some(stroke) = usvg_path.stroke() { + path.stroke = usvg_paint_to_color(&stroke.paint(), stroke.opacity().get()); + // Get the stroke width and scale it with the transform + let base_width = stroke.width().get() as f64; + // Apply transform scale to stroke width (use average of x and y scale) + let transform_array = transform.as_array(); + let scale_x = (transform_array[0] * transform_array[0] + + transform_array[1] * transform_array[1]) + .sqrt(); + let scale_y = (transform_array[2] * transform_array[2] + + transform_array[3] * transform_array[3]) + .sqrt(); + let avg_scale = (scale_x + scale_y) / 2.0; + path.stroke_width = base_width * avg_scale; + } else { + path.stroke = None; + } + + // Apply the transform to the path + let transformed_path = path.transform(transform); + + Some(transformed_path) +} + +/// Convert usvg path data to NodeBox contours. +fn convert_path_data(data: &usvg::tiny_skia_path::Path) -> Vec { + let mut contours: Vec = Vec::new(); + let mut current_contour: Option = None; + + for segment in data.segments() { + match segment { + PathSegment::MoveTo(pt) => { + // Start a new contour + if let Some(contour) = current_contour.take() { + if !contour.is_empty() { + contours.push(contour); + } + } + let mut new_contour = Contour::new(); + new_contour.move_to(pt.x as f64, pt.y as f64); + current_contour = Some(new_contour); + } + PathSegment::LineTo(pt) => { + if let Some(ref mut contour) = current_contour { + contour.line_to(pt.x as f64, pt.y as f64); + } + } + PathSegment::QuadTo(ctrl, end) => { + if let Some(ref mut contour) = current_contour { + contour.quad_to(ctrl.x as f64, ctrl.y as f64, end.x as f64, end.y as f64); + } + } + PathSegment::CubicTo(ctrl1, ctrl2, end) => { + if let Some(ref mut contour) = current_contour { + contour.curve_to( + ctrl1.x as f64, + ctrl1.y as f64, + ctrl2.x as f64, + ctrl2.y as f64, + end.x as f64, + end.y as f64, + ); + } + } + PathSegment::Close => { + if let Some(ref mut contour) = current_contour { + contour.close(); + } + } + } + } + + // Push any remaining contour + if let Some(contour) = current_contour { + if !contour.is_empty() { + contours.push(contour); + } + } + + contours +} + +/// Convert a usvg Paint to an optional NodeBox Color. +fn usvg_paint_to_color(paint: &usvg::Paint, opacity: f32) -> Option { + match paint { + usvg::Paint::Color(c) => { + // Convert u8 components (0-255) to f64 (0.0-1.0) + let r = c.red as f64 / 255.0; + let g = c.green as f64 / 255.0; + let b = c.blue as f64 / 255.0; + let a = opacity as f64; + Some(Color::rgba(r, g, b, a)) + } + usvg::Paint::LinearGradient(_) | usvg::Paint::RadialGradient(_) | usvg::Paint::Pattern(_) => { + // Gradients and patterns are not supported, return None + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_import_empty_content() { + let result = import_svg("", false, Point::ZERO); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_import_invalid_svg() { + let result = import_svg("not valid svg content", false, Point::ZERO); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse SVG")); + } + + #[test] + fn test_usvg_transform_identity() { + let usvg_t = usvg::Transform::identity(); + let nodebox_t = usvg_transform_to_nodebox(&usvg_t); + assert!(nodebox_t.is_identity()); + } + + #[test] + fn test_usvg_transform_translate() { + let usvg_t = usvg::Transform::from_translate(10.0, 20.0); + let nodebox_t = usvg_transform_to_nodebox(&usvg_t); + let p = nodebox_t.transform_point(Point::ZERO); + assert!((p.x - 10.0).abs() < 0.001); + assert!((p.y - 20.0).abs() < 0.001); + } + + #[test] + fn test_usvg_paint_to_color_solid() { + let paint = usvg::Paint::Color(usvg::Color::new_rgb(255, 128, 0)); + let color = usvg_paint_to_color(&paint, 1.0); + assert!(color.is_some()); + let c = color.unwrap(); + assert!((c.r - 1.0).abs() < 0.01); + assert!((c.g - 128.0 / 255.0).abs() < 0.01); + assert!((c.b - 0.0).abs() < 0.01); + assert!((c.a - 1.0).abs() < 0.01); + } + + #[test] + fn test_usvg_paint_to_color_with_opacity() { + let paint = usvg::Paint::Color(usvg::Color::new_rgb(255, 255, 255)); + let color = usvg_paint_to_color(&paint, 0.5); + assert!(color.is_some()); + let c = color.unwrap(); + assert!((c.a - 0.5).abs() < 0.02); + } + + #[test] + fn test_import_svg_from_content() { + let svg_content = r##" + + + + "##; + + let result = import_svg(svg_content, false, Point::ZERO); + assert!(result.is_ok(), "Failed to import SVG: {:?}", result); + + let geometry = result.unwrap(); + assert!(!geometry.is_empty(), "Geometry should not be empty"); + + // Should have at least 2 paths (rect and circle) + assert!( + geometry.len() >= 2, + "Should have at least 2 paths, got {}", + geometry.len() + ); + } + + #[test] + fn test_import_svg_centered() { + let svg_content = r##" + + + "##; + + // Import without centering + let not_centered = import_svg(svg_content, false, Point::ZERO).unwrap(); + let bounds_not_centered = not_centered.bounds().unwrap(); + + // Import with centering + let centered = import_svg(svg_content, true, Point::ZERO).unwrap(); + let bounds_centered = centered.bounds().unwrap(); + + // Centered version should have center at (0, 0) + let center = bounds_centered.center(); + assert!( + center.x.abs() < 1.0, + "Centered X should be near 0, got {}", + center.x + ); + assert!( + center.y.abs() < 1.0, + "Centered Y should be near 0, got {}", + center.y + ); + + // Not centered should have different bounds + let not_centered_center = bounds_not_centered.center(); + assert!( + (not_centered_center.x - 50.0).abs() < 1.0, + "Not centered X should be near 50, got {}", + not_centered_center.x + ); + } + + #[test] + fn test_import_svg_with_position() { + let svg_content = r##" + + + "##; + + let offset = Point::new(200.0, 300.0); + let result = import_svg(svg_content, true, offset).unwrap(); + let bounds = result.bounds().unwrap(); + + // Center should be at the offset position + let center = bounds.center(); + assert!( + (center.x - 200.0).abs() < 1.0, + "Center X should be 200, got {}", + center.x + ); + assert!( + (center.y - 300.0).abs() < 1.0, + "Center Y should be 300, got {}", + center.y + ); + } + + #[test] + fn test_import_svg_colors() { + let svg_content = r##" + + + "##; + + let result = import_svg(svg_content, false, Point::ZERO).unwrap(); + assert!(!result.is_empty()); + + let path = &result.paths[0]; + + // Check fill color (should be red) + assert!(path.fill.is_some(), "Path should have fill"); + let fill = path.fill.unwrap(); + assert!( + (fill.r - 1.0).abs() < 0.01, + "Fill red should be 1.0, got {}", + fill.r + ); + assert!(fill.g < 0.01, "Fill green should be 0.0, got {}", fill.g); + assert!(fill.b < 0.01, "Fill blue should be 0.0, got {}", fill.b); + + // Check stroke color (should be blue) + assert!(path.stroke.is_some(), "Path should have stroke"); + let stroke = path.stroke.unwrap(); + assert!(stroke.r < 0.01, "Stroke red should be 0.0, got {}", stroke.r); + assert!( + stroke.g < 0.01, + "Stroke green should be 0.0, got {}", + stroke.g + ); + assert!( + (stroke.b - 1.0).abs() < 0.01, + "Stroke blue should be 1.0, got {}", + stroke.b + ); + + // Check stroke width + assert!( + (path.stroke_width - 2.0).abs() < 0.1, + "Stroke width should be 2.0, got {}", + path.stroke_width + ); + } +} diff --git a/crates/nodebox-core/src/platform.rs b/crates/nodebox-core/src/platform.rs new file mode 100644 index 00000000..e5f2a22f --- /dev/null +++ b/crates/nodebox-core/src/platform.rs @@ -0,0 +1,786 @@ +//! Platform abstraction layer for NodeBox. +//! +//! The Platform system provides a unified interface for platform-specific I/O operations, +//! enabling the same core logic to run across desktop (macOS, Windows, Linux), +//! web (WASM), and mobile (iOS, Android) platforms. +//! +//! # Design Principles +//! +//! 1. **Single trait with runtime capability checking** - One `Platform` trait; +//! unsupported operations return `Err(PlatformError::Unsupported)` +//! 2. **Synchronous API** - All operations are blocking +//! 3. **Explicit context passing** - `ProjectContext` passed to operations; no global state +//! 4. **Sandboxed file access** - Files accessible only within project directory, +//! its subdirectories, and explicit library paths + +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// Information about the current platform. +#[derive(Debug, Clone)] +pub struct PlatformInfo { + /// Operating system name: "macos", "windows", "linux", "web", "ios", "android" + pub os_name: String, + /// Whether running in a web browser (WASM) + pub is_web: bool, + /// Whether running on a mobile platform (iOS/Android) + pub is_mobile: bool, + /// Whether native filesystem access is available + pub has_filesystem: bool, + /// Whether OS-native dialogs are available + pub has_native_dialogs: bool, +} + +impl PlatformInfo { + /// Create platform info for the current platform. + #[cfg(target_os = "macos")] + pub fn current() -> Self { + Self { + os_name: "macos".to_string(), + is_web: false, + is_mobile: false, + has_filesystem: true, + has_native_dialogs: true, + } + } + + #[cfg(target_os = "windows")] + pub fn current() -> Self { + Self { + os_name: "windows".to_string(), + is_web: false, + is_mobile: false, + has_filesystem: true, + has_native_dialogs: true, + } + } + + #[cfg(target_os = "linux")] + pub fn current() -> Self { + Self { + os_name: "linux".to_string(), + is_web: false, + is_mobile: false, + has_filesystem: true, + has_native_dialogs: true, + } + } + + #[cfg(target_arch = "wasm32")] + pub fn current() -> Self { + Self { + os_name: "web".to_string(), + is_web: true, + is_mobile: false, + has_filesystem: false, + has_native_dialogs: false, + } + } + + #[cfg(target_os = "ios")] + pub fn current() -> Self { + Self { + os_name: "ios".to_string(), + is_web: false, + is_mobile: true, + has_filesystem: true, + has_native_dialogs: true, + } + } + + #[cfg(target_os = "android")] + pub fn current() -> Self { + Self { + os_name: "android".to_string(), + is_web: false, + is_mobile: true, + has_filesystem: true, + has_native_dialogs: true, + } + } + + // Fallback for other platforms + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "ios", + target_os = "android", + target_arch = "wasm32" + )))] + pub fn current() -> Self { + Self { + os_name: "unknown".to_string(), + is_web: false, + is_mobile: false, + has_filesystem: true, + has_native_dialogs: false, + } + } +} + +/// Context for project-relative file operations. +#[derive(Debug, Clone)] +pub struct ProjectContext { + /// Root directory of the project (contains the .ndbx file). + /// None for unsaved projects. + pub root: Option, + /// Name of the project file within root. + /// None for unsaved projects. + pub project_file: Option, + /// Current frame number for animation. + pub frame: u32, +} + +impl ProjectContext { + /// Create context for a new unsaved project. + pub fn new_unsaved() -> Self { + Self { + root: None, + project_file: None, + frame: 1, + } + } + + /// Create context for a saved project. + pub fn new(root: impl Into, project_file: impl Into) -> Self { + Self { + root: Some(root.into()), + project_file: Some(project_file.into()), + frame: 1, + } + } + + /// Check if this project has been saved. + pub fn is_saved(&self) -> bool { + self.root.is_some() + } + + /// Get the full path to the project file. + /// Returns None for unsaved projects. + pub fn project_path(&self) -> Option { + match (&self.root, &self.project_file) { + (Some(root), Some(file)) => Some(root.join(file)), + _ => None, + } + } +} + +/// A path relative to project root that cannot escape the project directory. +/// +/// This type ensures that file access is sandboxed within the project directory. +/// Paths containing ".." components or starting with "/" are rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelativePath { + path: PathBuf, +} + +impl RelativePath { + /// Create a new relative path. + /// + /// # Errors + /// + /// Returns `PlatformError::SandboxViolation` if: + /// - The path contains ".." components + /// - The path starts with "/" (absolute path) + /// - The path starts with a Windows drive letter (e.g., "C:") + pub fn new(path: impl AsRef) -> Result { + let path = path.as_ref(); + + // Check for absolute paths + if path.is_absolute() { + return Err(PlatformError::SandboxViolation); + } + + // Check for Windows-style absolute paths (C:\, D:\, etc.) + if let Some(s) = path.to_str() { + if s.len() >= 2 { + let chars: Vec = s.chars().take(2).collect(); + if chars[0].is_ascii_alphabetic() && chars[1] == ':' { + return Err(PlatformError::SandboxViolation); + } + } + } + + // Check for ".." components that could escape the sandbox + for component in path.components() { + if let std::path::Component::ParentDir = component { + return Err(PlatformError::SandboxViolation); + } + } + + Ok(Self { + path: path.to_path_buf(), + }) + } + + /// Get the path as a `Path` reference. + pub fn as_path(&self) -> &Path { + &self.path + } + + /// Join this relative path with another path component. + /// + /// # Errors + /// + /// Returns `PlatformError::SandboxViolation` if the resulting path would escape the sandbox. + pub fn join(&self, path: impl AsRef) -> Result { + let joined = self.path.join(path); + Self::new(joined) + } +} + +impl AsRef for RelativePath { + fn as_ref(&self) -> &Path { + &self.path + } +} + +impl std::fmt::Display for RelativePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.path.display()) + } +} + +/// An entry in a directory listing. +#[derive(Debug, Clone)] +pub struct DirectoryEntry { + /// Name of the file or directory + pub name: String, + /// Whether this entry is a directory + pub is_directory: bool, +} + +impl DirectoryEntry { + /// Create a new directory entry. + pub fn new(name: impl Into, is_directory: bool) -> Self { + Self { + name: name.into(), + is_directory, + } + } +} + +/// Filter for file dialogs. +#[derive(Debug, Clone)] +pub struct FileFilter { + /// Display name for the filter (e.g., "NodeBox Files") + pub name: String, + /// File extensions to filter (e.g., ["ndbx"]) + pub extensions: Vec, +} + +impl FileFilter { + /// Create a new file filter. + pub fn new(name: impl Into, extensions: Vec) -> Self { + Self { + name: name.into(), + extensions, + } + } + + /// Create a filter for NodeBox files. + pub fn nodebox() -> Self { + Self::new("NodeBox Files", vec!["ndbx".to_string()]) + } + + /// Create a filter for SVG files. + pub fn svg() -> Self { + Self::new("SVG Files", vec!["svg".to_string()]) + } + + /// Create a filter for PNG files. + pub fn png() -> Self { + Self::new("PNG Files", vec!["png".to_string()]) + } + + /// Create a filter for CSV files. + pub fn csv() -> Self { + Self::new("CSV Files", vec!["csv".to_string(), "tsv".to_string()]) + } + + /// Create a filter for text files. + pub fn text() -> Self { + Self::new("Text Files", vec!["txt".to_string(), "text".to_string(), "csv".to_string(), "tsv".to_string(), "log".to_string()]) + } +} + +/// Log level for the `log` method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + /// Error messages + Error, + /// Warning messages + Warn, + /// Informational messages + Info, + /// Debug messages + Debug, +} + +impl std::fmt::Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogLevel::Error => write!(f, "ERROR"), + LogLevel::Warn => write!(f, "WARN"), + LogLevel::Info => write!(f, "INFO"), + LogLevel::Debug => write!(f, "DEBUG"), + } + } +} + +/// Errors that can occur during Port operations. +#[derive(Debug, Error)] +pub enum PlatformError { + /// Operation not supported on this platform + #[error("operation not supported on this platform")] + Unsupported, + + /// File or directory not found + #[error("not found")] + NotFound, + + /// Permission denied + #[error("permission denied")] + PermissionDenied, + + /// Path escapes sandbox (tried to access outside project dir) + #[error("path escapes project sandbox")] + SandboxViolation, + + /// Network request failed + #[error("network error: {0}")] + NetworkError(String), + + /// I/O error + #[error("I/O error: {0}")] + IoError(String), + + /// Library not found + #[error("library not found: {0}")] + LibraryNotFound(String), + + /// Other error + #[error("{0}")] + Other(String), +} + +impl From for PlatformError { + fn from(err: std::io::Error) -> Self { + match err.kind() { + std::io::ErrorKind::NotFound => PlatformError::NotFound, + std::io::ErrorKind::PermissionDenied => PlatformError::PermissionDenied, + _ => PlatformError::IoError(err.to_string()), + } + } +} + +/// The main Platform trait for platform abstraction. +/// +/// Implementations of this trait provide platform-specific behavior for +/// file I/O, dialogs, clipboard, network, and other operations. +pub trait Platform: Send + Sync { + // === Platform Info === + + /// Get information about the current platform. + fn platform_info(&self) -> PlatformInfo; + + // === File Operations === + + /// Read a file from the project directory. + fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PlatformError>; + + /// Write a file to the project directory. + fn write_file( + &self, + ctx: &ProjectContext, + path: &RelativePath, + data: &[u8], + ) -> Result<(), PlatformError>; + + /// List contents of a directory within the project. + fn list_directory( + &self, + ctx: &ProjectContext, + path: &RelativePath, + ) -> Result, PlatformError>; + + // === Convenience File Operations === + + /// Read a text file (UTF-8) from the project directory. + fn read_text_file(&self, ctx: &ProjectContext, path: &str) -> Result; + + /// Read a binary file from the project directory. + fn read_binary_file(&self, ctx: &ProjectContext, path: &str) -> Result, PlatformError>; + + /// Load an application resource (icons, fonts, etc.) + fn load_app_resource(&self, name: &str) -> Result, PlatformError>; + + // === Project File (special handling) === + + /// Read the project file. + fn read_project(&self, ctx: &ProjectContext) -> Result, PlatformError>; + + /// Write the project file. + fn write_project(&self, ctx: &ProjectContext, data: &[u8]) -> Result<(), PlatformError>; + + // === Library Access === + + /// Load a library by name. + fn load_library(&self, name: &str) -> Result, PlatformError>; + + // === Network === + + /// Perform an HTTP GET request. + fn http_get(&self, url: &str) -> Result, PlatformError>; + + // === Dialogs (Project-level, return absolute paths) === + + /// Show dialog to open a project file (no sandbox restriction). + fn show_open_project_dialog( + &self, + filters: &[FileFilter], + ) -> Result, PlatformError>; + + /// Show dialog to choose where to save a new project. + fn show_save_project_dialog( + &self, + filters: &[FileFilter], + default_name: Option<&str>, + ) -> Result, PlatformError>; + + // === Dialogs (Asset-level, sandboxed to project) === + + /// Show "Open File" dialog for importing assets. + fn show_open_file_dialog( + &self, + ctx: &ProjectContext, + filters: &[FileFilter], + ) -> Result, PlatformError>; + + /// Show "Save File" dialog for exporting assets. + fn show_save_file_dialog( + &self, + ctx: &ProjectContext, + filters: &[FileFilter], + default_name: Option<&str>, + ) -> Result, PlatformError>; + + /// Show a "Select Folder" dialog for selecting a directory within the project. + fn show_select_folder_dialog( + &self, + ctx: &ProjectContext, + ) -> Result, PlatformError>; + + /// Show a confirmation dialog with OK and Cancel buttons. + fn show_confirm_dialog(&self, title: &str, message: &str) -> Result; + + /// Show a message dialog with custom buttons. + fn show_message_dialog( + &self, + title: &str, + message: &str, + buttons: &[&str], + ) -> Result, PlatformError>; + + // === Clipboard === + + /// Read text from the clipboard. + fn clipboard_read_text(&self) -> Result, PlatformError>; + + /// Write text to the clipboard. + fn clipboard_write_text(&self, text: &str) -> Result<(), PlatformError>; + + // === Logging === + + /// Log a message at the specified level. + fn log(&self, level: LogLevel, message: &str); + + // === Performance === + + /// Create a performance mark. + fn performance_mark(&self, name: &str); + + /// Create a performance mark with additional details. + fn performance_mark_with_details(&self, name: &str, details: &str); + + // === Configuration === + + /// Get the configuration directory for storing app settings. + fn get_config_dir(&self) -> Result; + + /// List available font families on the system. + fn list_fonts(&self) -> Vec; +} + +/// A minimal Platform implementation for testing. +/// +/// Returns `Unsupported` for most operations, making it suitable +/// for tests that don't need actual file or dialog operations. +pub struct TestPlatform; + +impl TestPlatform { + /// Create a new TestPlatform. + pub fn new() -> Self { + Self + } +} + +impl Default for TestPlatform { + fn default() -> Self { + Self::new() + } +} + +impl Platform for TestPlatform { + fn platform_info(&self) -> PlatformInfo { + PlatformInfo { + os_name: "test".to_string(), + is_web: false, + is_mobile: false, + has_filesystem: false, + has_native_dialogs: false, + } + } + + fn read_file(&self, _ctx: &ProjectContext, _path: &RelativePath) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn write_file( + &self, + _ctx: &ProjectContext, + _path: &RelativePath, + _data: &[u8], + ) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported) + } + + fn list_directory( + &self, + _ctx: &ProjectContext, + _path: &RelativePath, + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn read_text_file(&self, _ctx: &ProjectContext, _path: &str) -> Result { + Err(PlatformError::Unsupported) + } + + fn read_binary_file(&self, _ctx: &ProjectContext, _path: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn load_app_resource(&self, _name: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn read_project(&self, _ctx: &ProjectContext) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn write_project(&self, _ctx: &ProjectContext, _data: &[u8]) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported) + } + + fn load_library(&self, _name: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn http_get(&self, _url: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn show_open_project_dialog( + &self, + _filters: &[FileFilter], + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn show_save_project_dialog( + &self, + _filters: &[FileFilter], + _default_name: Option<&str>, + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn show_open_file_dialog( + &self, + _ctx: &ProjectContext, + _filters: &[FileFilter], + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn show_save_file_dialog( + &self, + _ctx: &ProjectContext, + _filters: &[FileFilter], + _default_name: Option<&str>, + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn show_select_folder_dialog( + &self, + _ctx: &ProjectContext, + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn show_confirm_dialog(&self, _title: &str, _message: &str) -> Result { + Err(PlatformError::Unsupported) + } + + fn show_message_dialog( + &self, + _title: &str, + _message: &str, + _buttons: &[&str], + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn clipboard_read_text(&self) -> Result, PlatformError> { + Err(PlatformError::Unsupported) + } + + fn clipboard_write_text(&self, _text: &str) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported) + } + + fn log(&self, _level: LogLevel, _message: &str) {} + + fn performance_mark(&self, _name: &str) {} + + fn performance_mark_with_details(&self, _name: &str, _details: &str) {} + + fn get_config_dir(&self) -> Result { + Err(PlatformError::Unsupported) + } + + fn list_fonts(&self) -> Vec { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relative_path_valid() { + assert!(RelativePath::new("file.txt").is_ok()); + assert!(RelativePath::new("subdir/file.txt").is_ok()); + assert!(RelativePath::new("a/b/c/d.txt").is_ok()); + assert!(RelativePath::new("").is_ok()); + } + + #[test] + fn test_relative_path_rejects_parent_dir() { + assert!(matches!(RelativePath::new(".."), Err(PlatformError::SandboxViolation))); + assert!(matches!(RelativePath::new("../file.txt"), Err(PlatformError::SandboxViolation))); + assert!(matches!(RelativePath::new("subdir/../other.txt"), Err(PlatformError::SandboxViolation))); + assert!(matches!(RelativePath::new("a/b/../../c.txt"), Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_relative_path_rejects_absolute() { + assert!(matches!(RelativePath::new("/etc/passwd"), Err(PlatformError::SandboxViolation))); + assert!(matches!(RelativePath::new("/home/user/file.txt"), Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_relative_path_rejects_windows_absolute() { + assert!(matches!(RelativePath::new("C:/Users/file.txt"), Err(PlatformError::SandboxViolation))); + assert!(matches!(RelativePath::new("D:\\Documents\\file.txt"), Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_relative_path_join() { + let base = RelativePath::new("subdir").unwrap(); + let joined = base.join("file.txt").unwrap(); + assert_eq!(joined.as_path(), Path::new("subdir/file.txt")); + assert!(matches!(base.join("../escape.txt"), Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_relative_path_display() { + let path = RelativePath::new("subdir/file.txt").unwrap(); + assert_eq!(format!("{}", path), "subdir/file.txt"); + } + + #[test] + fn test_project_context() { + let ctx = ProjectContext::new("/home/user/project", "myproject.ndbx"); + assert_eq!(ctx.root, Some(PathBuf::from("/home/user/project"))); + assert_eq!(ctx.project_file, Some("myproject.ndbx".to_string())); + assert!(ctx.is_saved()); + assert_eq!(ctx.project_path(), Some(PathBuf::from("/home/user/project/myproject.ndbx"))); + } + + #[test] + fn test_project_context_unsaved() { + let ctx = ProjectContext::new_unsaved(); + assert_eq!(ctx.root, None); + assert_eq!(ctx.project_file, None); + assert!(!ctx.is_saved()); + assert_eq!(ctx.project_path(), None); + } + + #[test] + fn test_directory_entry() { + let file = DirectoryEntry::new("file.txt", false); + assert_eq!(file.name, "file.txt"); + assert!(!file.is_directory); + let dir = DirectoryEntry::new("subdir", true); + assert_eq!(dir.name, "subdir"); + assert!(dir.is_directory); + } + + #[test] + fn test_file_filter() { + let filter = FileFilter::new("Images", vec!["png".to_string(), "jpg".to_string()]); + assert_eq!(filter.name, "Images"); + assert_eq!(filter.extensions, vec!["png", "jpg"]); + let ndbx = FileFilter::nodebox(); + assert_eq!(ndbx.extensions, vec!["ndbx"]); + } + + #[test] + fn test_log_level_display() { + assert_eq!(format!("{}", LogLevel::Error), "ERROR"); + assert_eq!(format!("{}", LogLevel::Warn), "WARN"); + assert_eq!(format!("{}", LogLevel::Info), "INFO"); + assert_eq!(format!("{}", LogLevel::Debug), "DEBUG"); + } + + #[test] + fn test_port_error_from_io_error() { + let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "not found"); + assert!(matches!(PlatformError::from(not_found), PlatformError::NotFound)); + let permission = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); + assert!(matches!(PlatformError::from(permission), PlatformError::PermissionDenied)); + let other = std::io::Error::new(std::io::ErrorKind::Other, "something else"); + assert!(matches!(PlatformError::from(other), PlatformError::IoError(_))); + } + + #[test] + fn test_port_error_display() { + assert_eq!(format!("{}", PlatformError::Unsupported), "operation not supported on this platform"); + assert_eq!(format!("{}", PlatformError::NotFound), "not found"); + assert_eq!(format!("{}", PlatformError::PermissionDenied), "permission denied"); + assert_eq!(format!("{}", PlatformError::SandboxViolation), "path escapes project sandbox"); + assert_eq!(format!("{}", PlatformError::NetworkError("timeout".to_string())), "network error: timeout"); + assert_eq!(format!("{}", PlatformError::LibraryNotFound("math".to_string())), "library not found: math"); + } + + #[test] + fn test_platform_info_current() { + let info = PlatformInfo::current(); + assert!(!info.os_name.is_empty()); + } +} diff --git a/crates/nodebox-core/src/svg/mod.rs b/crates/nodebox-core/src/svg/mod.rs new file mode 100644 index 00000000..471accc2 --- /dev/null +++ b/crates/nodebox-core/src/svg/mod.rs @@ -0,0 +1,7 @@ +//! SVG rendering for NodeBox. +//! +//! Converts NodeBox geometry to SVG format. + +mod renderer; + +pub use renderer::*; diff --git a/crates/nodebox-svg/src/renderer.rs b/crates/nodebox-core/src/svg/renderer.rs similarity index 85% rename from crates/nodebox-svg/src/renderer.rs rename to crates/nodebox-core/src/svg/renderer.rs index a90e0e6e..ed6ead92 100644 --- a/crates/nodebox-svg/src/renderer.rs +++ b/crates/nodebox-core/src/svg/renderer.rs @@ -1,6 +1,6 @@ //! SVG rendering implementation. -use nodebox_core::geometry::{Path, Geometry, Color, Contour, PointType, Text, TextAlign, Canvas, Grob}; +use crate::geometry::{Path, Geometry, Color, Contour, PointType, Text, TextAlign, Canvas, Grob}; use std::fmt::Write; /// Options for SVG rendering. @@ -76,7 +76,7 @@ impl SvgOptions { /// # Example /// ``` /// use nodebox_core::geometry::Path; -/// use nodebox_svg::render_to_svg; +/// use nodebox_core::svg::render_to_svg; /// /// let circle = Path::ellipse(100.0, 100.0, 80.0, 80.0); /// let svg = render_to_svg(&[circle], 200.0, 200.0); @@ -102,14 +102,7 @@ pub fn render_to_svg_with_options(paths: &[Path], options: &SvgOptions) -> Strin write!(svg, r#" width="{}" height="{}""#, options.width, options.height).unwrap(); if options.include_viewbox { - if options.centered { - // Centered viewBox: origin is at center of canvas - let half_w = options.width / 2.0; - let half_h = options.height / 2.0; - write!(svg, r#" viewBox="{} {} {} {}""#, -half_w, -half_h, options.width, options.height).unwrap(); - } else { - write!(svg, r#" viewBox="0 0 {} {}""#, options.width, options.height).unwrap(); - } + write!(svg, r#" viewBox="0 0 {} {}""#, options.width, options.height).unwrap(); } writeln!(svg, ">").unwrap(); @@ -118,16 +111,27 @@ pub fn render_to_svg_with_options(paths: &[Path], options: &SvgOptions) -> Strin if let Some(bg) = options.background { writeln!( svg, - r#" "#, - color_to_svg(&bg) + r#" "#, + options.width, options.height, color_to_svg(&bg) ).unwrap(); } + // When centered, wrap geometry in a translate group so (0,0) maps to canvas center + if options.centered { + let half_w = options.width / 2.0; + let half_h = options.height / 2.0; + writeln!(svg, r#" "#).unwrap(); + } + // Render paths for path in paths { render_path(&mut svg, path, options.precision); } + if options.centered { + writeln!(svg, " ").unwrap(); + } + writeln!(svg, "").unwrap(); svg @@ -164,8 +168,8 @@ pub fn render_canvas_to_svg(canvas: &Canvas) -> String { if let Some(bg) = options.background { writeln!( svg, - r#" "#, - color_to_svg(&bg) + r#" "#, + options.width, options.height, color_to_svg(&bg) ).unwrap(); } @@ -322,6 +326,31 @@ fn contour_to_svg_data(data: &mut String, contour: &Contour, precision: usize) { write!(data, " L{:.prec$},{:.prec$}", pt.point.x, pt.point.y, prec = precision).unwrap(); i += 1; } + PointType::QuadData => { + // Quadratic bezier: ctrl, end + if i + 1 < points.len() { + let ctrl = &points[i]; + let end = &points[i + 1]; + + write!( + data, + " Q{:.prec$},{:.prec$} {:.prec$},{:.prec$}", + ctrl.point.x, ctrl.point.y, + end.point.x, end.point.y, + prec = precision + ).unwrap(); + + i += 2; + } else { + // Malformed curve data, skip + i += 1; + } + } + PointType::QuadTo => { + // This shouldn't happen without preceding QuadData + write!(data, " L{:.prec$},{:.prec$}", pt.point.x, pt.point.y, prec = precision).unwrap(); + i += 1; + } } } @@ -430,7 +459,7 @@ mod tests { let svg = render_to_svg_with_options(&[], &options); // Should not have background rect - assert!(!svg.contains(r#" = library.root.children.iter().map(|n| n.name.as_str()).collect(); @@ -73,9 +62,9 @@ fn test_parse_corevector_library() { assert!(rect_port_names.contains(&"height"), "rect missing height port"); } -/// Test parsing a simple demo file. +/// Test that parsing a very old demo file (version 0) succeeds best-effort with warnings. #[test] -fn test_parse_demo_file() { +fn test_parse_old_demo_file_loads_with_warning() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../src/test/files/demo.ndbx"); @@ -84,8 +73,8 @@ fn test_parse_demo_file() { return; } - let library = parse_file(&path).expect("Failed to parse demo.ndbx"); - - // Should parse without error and have a root node with a name - assert!(!library.root.name.is_empty()); + // This file has an old/missing format version but should still load best-effort + let (library, _warnings) = parse_file_with_warnings(&path) + .expect("Old demo file should load best-effort"); + assert_eq!(library.format_version, 22, "Should be upgraded to current version"); } diff --git a/crates/nodebox-gui/Cargo.toml b/crates/nodebox-desktop/Cargo.toml similarity index 59% rename from crates/nodebox-gui/Cargo.toml rename to crates/nodebox-desktop/Cargo.toml index 6c4c9d3c..a55e1fea 100644 --- a/crates/nodebox-gui/Cargo.toml +++ b/crates/nodebox-desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "nodebox-gui" -description = "Native GUI for NodeBox" +name = "nodebox-desktop" +description = "Native desktop GUI for NodeBox" version.workspace = true edition.workspace = true license.workspace = true @@ -8,14 +8,11 @@ repository.workspace = true authors.workspace = true [lib] -name = "nodebox_gui" +name = "nodebox_desktop" path = "src/lib.rs" [dependencies] nodebox-core = { path = "../nodebox-core" } -nodebox-ops = { path = "../nodebox-ops" } -nodebox-ndbx = { path = "../nodebox-ndbx" } -nodebox-svg = { path = "../nodebox-svg" } # GUI eframe = "0.33" @@ -23,8 +20,10 @@ egui = "0.33" egui_extras = { version = "0.33", features = ["image"] } egui-wgpu = { version = "0.33", optional = true } -# File dialogs -rfd = "0.15" +# Recent files persistence +directories = "5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" # Logging log = "0.4" @@ -34,10 +33,26 @@ env_logger = "0.11" image = { version = "0.25", features = ["png"] } tiny-skia = "0.11" +# Async runtime for cancellable rendering +smol = "2" + # Native menu bar (macOS) [target.'cfg(target_os = "macos")'.dependencies] muda = "0.15" +# DesktopPlatform dependencies (desktop only) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = "0.15" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.arboard] +version = "3" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.ureq] +version = "2" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.font-kit] +workspace = true + [features] default = ["gpu-rendering"] gpu-rendering = ["dep:vello", "dep:egui-wgpu", "dep:pollster", "eframe/wgpu"] diff --git a/crates/nodebox-desktop/src/address_bar.rs b/crates/nodebox-desktop/src/address_bar.rs new file mode 100644 index 00000000..54260a93 --- /dev/null +++ b/crates/nodebox-desktop/src/address_bar.rs @@ -0,0 +1,223 @@ +//! Address bar with breadcrumb navigation and stop button. + +#![allow(dead_code)] + +use eframe::egui::{self, Sense, Color32}; +use crate::theme; + +/// Time threshold in seconds before the stop button becomes prominent. +const STOP_BUTTON_HIGHLIGHT_THRESHOLD_SECS: f32 = 3.0; + +/// Action returned from the address bar. +#[derive(Debug, Clone, PartialEq)] +pub enum AddressBarAction { + /// No action taken. + None, + /// User clicked on a path segment; navigate to it. + NavigateTo(String), + /// User clicked the stop button to cancel rendering. + StopClicked, +} + +/// The address bar showing current network path. +pub struct AddressBar { + /// Path segments (e.g., ["root", "network1"]). + segments: Vec, + /// Hovered segment index (for highlighting). + hovered_segment: Option, + /// Whether rendering is currently in progress. + is_rendering: bool, + /// How long the current render has been running (in seconds). + render_elapsed_secs: f32, +} + +impl Default for AddressBar { + fn default() -> Self { + Self::new() + } +} + +impl AddressBar { + /// Create a new address bar. + pub fn new() -> Self { + Self { + segments: vec!["root".to_string()], + hovered_segment: None, + is_rendering: false, + render_elapsed_secs: 0.0, + } + } + + /// Update the rendering state for the stop button. + pub fn set_render_state(&mut self, is_rendering: bool, elapsed_secs: f32) { + self.is_rendering = is_rendering; + self.render_elapsed_secs = elapsed_secs; + } + + /// Set the current path from a path string (e.g., "/root/network1"). + pub fn set_path(&mut self, path: &str) { + self.segments = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(); + if self.segments.is_empty() { + self.segments.push("root".to_string()); + } + } + + /// Get the current path as a string. + pub fn path(&self) -> String { + format!("/{}", self.segments.join("/")) + } + + /// Show the address bar. Returns an action if user interacted with it. + pub fn show(&mut self, ui: &mut egui::Ui) -> AddressBarAction { + let mut action = AddressBarAction::None; + self.hovered_segment = None; + + // Clean background - uses panel bg for seamless integration + let rect = ui.available_rect_before_wrap(); + ui.painter().rect_filled(rect, 0.0, theme::PANEL_BG); + + // Use centered layout to vertically center all content + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add_space(theme::PADDING); + + // Draw path segments with separators - smaller, more subtle + for (i, segment) in self.segments.iter().enumerate() { + // Separator (except before first segment) + if i > 0 { + ui.label( + egui::RichText::new("/") + .color(theme::TEXT_DISABLED) + .size(11.0), + ); + } + + // Segment as clickable text - subtle styling + let is_last = i == self.segments.len() - 1; + let text_color = if is_last { + theme::TEXT_DEFAULT + } else { + theme::TEXT_SUBDUED + }; + + let response = ui.add( + egui::Label::new( + egui::RichText::new(segment) + .color(text_color) + .size(11.0), + ) + .sense(Sense::click()), + ); + + // Subtle hover effect + if response.hovered() { + self.hovered_segment = Some(i); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Handle click - navigate to this segment's path + if response.clicked() { + let path = format!( + "/{}", + self.segments[..=i].join("/") + ); + action = AddressBarAction::NavigateTo(path); + } + } + + // Right-aligned stop button + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(theme::PADDING); + + // Stop button + if self.draw_stop_button(ui) { + action = AddressBarAction::StopClicked; + } + }); + }); + + action + } + + /// Draw the stop button and return true if clicked. + fn draw_stop_button(&self, ui: &mut egui::Ui) -> bool { + // Determine button color based on rendering state + let button_color = if !self.is_rendering { + // Not rendering: subtle (disabled appearance) + theme::ZINC_600 + } else if self.render_elapsed_secs < STOP_BUTTON_HIGHLIGHT_THRESHOLD_SECS { + // Rendering but less than threshold: subtle + theme::ZINC_600 + } else { + // Rendering and past threshold: prominent + theme::ZINC_200 + }; + + // Draw the stop button (circle with square inside) + let size = 16.0; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(size, size), + if self.is_rendering { Sense::click() } else { Sense::hover() }, + ); + + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + let center = rect.center(); + let radius = size / 2.0 - 1.0; + + // Hover effect: slightly brighter when hovered and rendering + let color = if response.hovered() && self.is_rendering { + brighten_color(button_color, 0.2) + } else { + button_color + }; + + // Draw circle outline + painter.circle_stroke( + center, + radius, + egui::Stroke::new(1.5, color), + ); + + // Draw square inside (stop symbol) + let square_size = size / 3.0; + let square_rect = egui::Rect::from_center_size( + center, + egui::vec2(square_size, square_size), + ); + painter.rect_filled(square_rect, 0.0, color); + + // Show cursor hint when clickable + if response.hovered() && self.is_rendering { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + + // Check if clicked before consuming response for tooltip + let clicked = response.clicked() && self.is_rendering; + + // Add tooltip + if response.hovered() { + let tooltip_text = if self.is_rendering { + "Stop rendering (Cmd+.)" + } else { + "Stop (not rendering)" + }; + response.on_hover_text(tooltip_text); + } + + clicked + } +} + +/// Brighten a color by a factor (0.0 = no change, 1.0 = fully white). +fn brighten_color(color: Color32, factor: f32) -> Color32 { + let r = color.r() as f32 + (255.0 - color.r() as f32) * factor; + let g = color.g() as f32 + (255.0 - color.g() as f32) * factor; + let b = color.b() as f32 + (255.0 - color.b() as f32) * factor; + Color32::from_rgb(r as u8, g as u8, b as u8) +} diff --git a/crates/nodebox-gui/src/animation_bar.rs b/crates/nodebox-desktop/src/animation_bar.rs similarity index 50% rename from crates/nodebox-gui/src/animation_bar.rs rename to crates/nodebox-desktop/src/animation_bar.rs index d43ad365..ae378040 100644 --- a/crates/nodebox-gui/src/animation_bar.rs +++ b/crates/nodebox-desktop/src/animation_bar.rs @@ -1,8 +1,6 @@ //! Compact animation bar with playback controls. //! -//! Note: This module is work-in-progress and not yet integrated. - -#![allow(dead_code)] +//! Matches the Java AnimationBar: a draggable frame number, Play/Pause, and Rewind. use eframe::egui; use std::time::{Duration, Instant}; @@ -13,7 +11,6 @@ use crate::theme; pub enum PlaybackState { Stopped, Playing, - Paused, } /// Events that can be triggered by the animation bar. @@ -21,30 +18,22 @@ pub enum PlaybackState { pub enum AnimationEvent { None, Play, - Pause, Stop, Rewind, - StepBack, - StepForward, - GoToEnd, FrameChanged(f64), - FpsChanged(u32), } /// Compact animation bar widget. +/// +/// Controls: draggable frame number, Play/Pause button, Rewind button. +/// No max frame — the frame counts up indefinitely during playback. pub struct AnimationBar { - /// Current frame number. + /// Current frame number (1-based, no upper bound). frame: u32, - /// Start frame. - start_frame: u32, - /// End frame (total frames). - end_frame: u32, /// Frames per second. fps: u32, /// Current playback state. playback_state: PlaybackState, - /// Whether to loop the animation. - loop_enabled: bool, /// Time of last frame update. last_frame_time: Option, /// Accumulated time since last frame. @@ -62,11 +51,8 @@ impl AnimationBar { pub fn new() -> Self { Self { frame: 1, - start_frame: 1, - end_frame: 100, fps: 30, playback_state: PlaybackState::Stopped, - loop_enabled: true, last_frame_time: None, accumulated_time: Duration::ZERO, } @@ -77,24 +63,10 @@ impl AnimationBar { self.frame } - /// Get the current frame as f64. - pub fn frame_f64(&self) -> f64 { - self.frame as f64 - } - - /// Set the current frame. + /// Set the current frame (clamped to >= 1). + #[allow(dead_code)] pub fn set_frame(&mut self, frame: u32) { - self.frame = frame.clamp(self.start_frame, self.end_frame); - } - - /// Get the normalized time (0.0 to 1.0). - pub fn normalized_time(&self) -> f64 { - let range = (self.end_frame - self.start_frame) as f64; - if range > 0.0 { - (self.frame - self.start_frame) as f64 / range - } else { - 0.0 - } + self.frame = frame.max(1); } /// Is the animation playing? @@ -109,44 +81,16 @@ impl AnimationBar { self.accumulated_time = Duration::ZERO; } - /// Pause the animation. - pub fn pause(&mut self) { - self.playback_state = PlaybackState::Paused; - } - - /// Stop the animation and reset to start. + /// Stop the animation (keeps current frame). pub fn stop(&mut self) { self.playback_state = PlaybackState::Stopped; - self.frame = self.start_frame; self.last_frame_time = None; } - /// Step forward one frame. - pub fn step_forward(&mut self) { - if self.frame < self.end_frame { - self.frame += 1; - } else if self.loop_enabled { - self.frame = self.start_frame; - } - } - - /// Step backward one frame. - pub fn step_backward(&mut self) { - if self.frame > self.start_frame { - self.frame -= 1; - } else if self.loop_enabled { - self.frame = self.end_frame; - } - } - - /// Go to first frame. + /// Rewind: stop playback and reset to frame 1. pub fn rewind(&mut self) { - self.frame = self.start_frame; - } - - /// Go to last frame. - pub fn go_to_end(&mut self) { - self.frame = self.end_frame; + self.stop(); + self.frame = 1; } /// Update playback (call each frame). @@ -165,13 +109,7 @@ impl AnimationBar { if self.accumulated_time >= frame_duration { self.accumulated_time -= frame_duration; - self.step_forward(); - - // Stop at end if not looping - if !self.loop_enabled && self.frame >= self.end_frame { - self.playback_state = PlaybackState::Stopped; - } - + self.frame += 1; return true; } } else { @@ -201,136 +139,76 @@ impl AnimationBar { ui.horizontal(|ui| { ui.add_space(theme::PADDING_SMALL); - // Playback control buttons - flush with bar height, transparent background - if self.icon_button(ui, "⏮", "Rewind") { - self.rewind(); - event = AnimationEvent::Rewind; + // Frame number (draggable, min 1, no max) + let mut frame = self.frame as i32; + let frame_response = Self::styled_drag_value(ui, &mut frame, 1..=i32::MAX, 50.0); + if frame_response.changed() { + self.frame = (frame.max(1)) as u32; + event = AnimationEvent::FrameChanged(self.frame as f64); } - if self.icon_button(ui, "⏪", "Step backward") { - self.step_backward(); - event = AnimationEvent::StepBack; - } + ui.add_space(theme::PADDING_SMALL); + // Play/Pause toggle let (play_icon, play_tooltip) = if self.is_playing() { - ("⏸", "Pause") + ("\u{23F8}", "Stop") } else { - ("▶", "Play") + ("\u{25B6}", "Play") }; if self.icon_button(ui, play_icon, play_tooltip) { if self.is_playing() { - self.pause(); - event = AnimationEvent::Pause; + self.stop(); + event = AnimationEvent::Stop; } else { self.play(); event = AnimationEvent::Play; } } - if self.icon_button(ui, "⏩", "Step forward") { - self.step_forward(); - event = AnimationEvent::StepForward; - } - - if self.icon_button(ui, "⏭", "Go to end") { - self.go_to_end(); - event = AnimationEvent::GoToEnd; - } - - if self.icon_button(ui, "⏹", "Stop") { - self.stop(); - event = AnimationEvent::Stop; - } - - ui.add_space(theme::PADDING); - - // Frame counter - width for 3+ digits - ui.label( - egui::RichText::new("Frame") - .color(theme::TEXT_SUBDUED) - .size(theme::FONT_SIZE_SMALL), - ); - let mut frame = self.frame as i32; - let frame_response = Self::styled_drag_value(ui, &mut frame, self.start_frame as i32..=self.end_frame as i32, 40.0); - if frame_response.changed() { - self.frame = frame as u32; - event = AnimationEvent::FrameChanged(self.frame as f64); - } - - ui.label( - egui::RichText::new(format!("/{}", self.end_frame)) - .color(theme::TEXT_DISABLED) - .size(theme::FONT_SIZE_SMALL), - ); - - ui.add_space(theme::PADDING); - - // FPS control - width for 3 digits - ui.label( - egui::RichText::new("FPS") - .color(theme::TEXT_SUBDUED) - .size(theme::FONT_SIZE_SMALL), - ); - let mut fps = self.fps as i32; - let fps_response = Self::styled_drag_value(ui, &mut fps, 1..=120, 40.0); - if fps_response.changed() { - self.fps = fps as u32; - event = AnimationEvent::FpsChanged(self.fps); + // Rewind + if self.icon_button(ui, "\u{23EE}", "Rewind") { + self.rewind(); + event = AnimationEvent::Rewind; } - - ui.add_space(theme::PADDING); - - // Loop toggle - Self::styled_checkbox(ui, &mut self.loop_enabled); - ui.label( - egui::RichText::new("Loop") - .color(if self.loop_enabled { theme::TEXT_DEFAULT } else { theme::TEXT_SUBDUED }) - .size(theme::FONT_SIZE_SMALL), - ); }); event } /// Styled DragValue that follows the style guide. - /// Uses SLATE_800 for subtle elevation against SLATE_900 bar background. fn styled_drag_value(ui: &mut egui::Ui, value: &mut i32, range: std::ops::RangeInclusive, width: f32) -> egui::Response { - // Override visuals and spacing for this widget let old_visuals = ui.visuals().clone(); let old_spacing = ui.spacing().clone(); - // All states: no borders, sharp corners, appropriate fill - ui.visuals_mut().widgets.inactive.bg_fill = theme::SLATE_800; - ui.visuals_mut().widgets.inactive.weak_bg_fill = theme::SLATE_800; + ui.visuals_mut().widgets.inactive.bg_fill = theme::ZINC_700; + ui.visuals_mut().widgets.inactive.weak_bg_fill = theme::ZINC_700; ui.visuals_mut().widgets.inactive.bg_stroke = egui::Stroke::NONE; ui.visuals_mut().widgets.inactive.fg_stroke = egui::Stroke::new(1.0, theme::TEXT_DEFAULT); ui.visuals_mut().widgets.inactive.corner_radius = egui::CornerRadius::ZERO; ui.visuals_mut().widgets.inactive.expansion = 0.0; - ui.visuals_mut().widgets.hovered.bg_fill = theme::SLATE_700; - ui.visuals_mut().widgets.hovered.weak_bg_fill = theme::SLATE_700; + ui.visuals_mut().widgets.hovered.bg_fill = theme::ZINC_600; + ui.visuals_mut().widgets.hovered.weak_bg_fill = theme::ZINC_600; ui.visuals_mut().widgets.hovered.bg_stroke = egui::Stroke::NONE; ui.visuals_mut().widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme::TEXT_STRONG); ui.visuals_mut().widgets.hovered.corner_radius = egui::CornerRadius::ZERO; ui.visuals_mut().widgets.hovered.expansion = 0.0; - ui.visuals_mut().widgets.active.bg_fill = theme::SLATE_700; - ui.visuals_mut().widgets.active.weak_bg_fill = theme::SLATE_700; + ui.visuals_mut().widgets.active.bg_fill = theme::ZINC_600; + ui.visuals_mut().widgets.active.weak_bg_fill = theme::ZINC_600; ui.visuals_mut().widgets.active.bg_stroke = egui::Stroke::NONE; ui.visuals_mut().widgets.active.fg_stroke = egui::Stroke::new(1.0, theme::TEXT_STRONG); ui.visuals_mut().widgets.active.corner_radius = egui::CornerRadius::ZERO; ui.visuals_mut().widgets.active.expansion = 0.0; - ui.visuals_mut().widgets.noninteractive.bg_fill = theme::SLATE_800; - ui.visuals_mut().widgets.noninteractive.weak_bg_fill = theme::SLATE_800; + ui.visuals_mut().widgets.noninteractive.bg_fill = theme::ZINC_700; + ui.visuals_mut().widgets.noninteractive.weak_bg_fill = theme::ZINC_700; ui.visuals_mut().widgets.noninteractive.bg_stroke = egui::Stroke::NONE; ui.visuals_mut().widgets.noninteractive.corner_radius = egui::CornerRadius::ZERO; ui.visuals_mut().widgets.noninteractive.expansion = 0.0; - // Use consistent padding for button and text edit modes ui.spacing_mut().button_padding = egui::vec2(4.0, 2.0); - // Allocate exact size and place widget inside to prevent any shifting let (rect, _) = ui.allocate_exact_size( egui::vec2(width, theme::ANIMATION_BAR_HEIGHT), egui::Sense::hover(), @@ -342,63 +220,26 @@ impl AnimationBar { .speed(1.0), ); - // Restore visuals and spacing *ui.visuals_mut() = old_visuals; *ui.spacing_mut() = old_spacing; response } - /// Styled checkbox that follows the style guide. - fn styled_checkbox(ui: &mut egui::Ui, checked: &mut bool) -> egui::Response { - // Override visuals for this widget - let old_visuals = ui.visuals().clone(); - - ui.visuals_mut().widgets.inactive.bg_fill = theme::SLATE_800; - ui.visuals_mut().widgets.inactive.weak_bg_fill = theme::SLATE_800; - ui.visuals_mut().widgets.inactive.bg_stroke = egui::Stroke::NONE; - ui.visuals_mut().widgets.inactive.corner_radius = egui::CornerRadius::ZERO; - - ui.visuals_mut().widgets.hovered.bg_fill = theme::SLATE_700; - ui.visuals_mut().widgets.hovered.weak_bg_fill = theme::SLATE_700; - ui.visuals_mut().widgets.hovered.bg_stroke = egui::Stroke::NONE; - ui.visuals_mut().widgets.hovered.corner_radius = egui::CornerRadius::ZERO; - - ui.visuals_mut().widgets.active.bg_fill = theme::SLATE_700; - ui.visuals_mut().widgets.active.weak_bg_fill = theme::SLATE_700; - ui.visuals_mut().widgets.active.bg_stroke = egui::Stroke::NONE; - ui.visuals_mut().widgets.active.corner_radius = egui::CornerRadius::ZERO; - - ui.visuals_mut().widgets.noninteractive.bg_fill = theme::SLATE_800; - ui.visuals_mut().widgets.noninteractive.weak_bg_fill = theme::SLATE_800; - ui.visuals_mut().widgets.noninteractive.bg_stroke = egui::Stroke::NONE; - ui.visuals_mut().widgets.noninteractive.corner_radius = egui::CornerRadius::ZERO; - - let response = ui.checkbox(checked, ""); - - // Restore visuals - *ui.visuals_mut() = old_visuals; - - response - } - /// Custom icon button with transparent background and hover effect. - /// Returns true if clicked. fn icon_button(&self, ui: &mut egui::Ui, icon: &str, tooltip: &str) -> bool { let button_size = egui::vec2(theme::ANIMATION_BAR_HEIGHT, theme::ANIMATION_BAR_HEIGHT); let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click()); if ui.is_rect_visible(rect) { - // Transparent background, lighter on hover let bg_color = if response.hovered() { - theme::SLATE_800 + theme::ZINC_700 } else { egui::Color32::TRANSPARENT }; ui.painter().rect_filled(rect, 0.0, bg_color); - // Icon color - brighter on hover let text_color = if response.hovered() { theme::TEXT_STRONG } else { diff --git a/crates/nodebox-desktop/src/app.rs b/crates/nodebox-desktop/src/app.rs new file mode 100644 index 00000000..4d577d6a --- /dev/null +++ b/crates/nodebox-desktop/src/app.rs @@ -0,0 +1,2024 @@ +//! Main application state and update loop. + +use eframe::egui::{self, Pos2, Rect}; +use nodebox_core::geometry::Point; +use nodebox_core::platform::{Platform, ProjectContext}; +use std::sync::Arc; + +use crate::address_bar::{AddressBar, AddressBarAction}; +use crate::animation_bar::{AnimationBar, AnimationEvent}; +use crate::components; +use crate::history::{History, SelectionSnapshot}; +use crate::icon_cache::IconCache; +use crate::native_menu::{MenuAction, NativeMenuHandle}; +use crate::notification_banner; +use crate::recent_files::RecentFiles; +use crate::network_view::{NetworkAction, NetworkView}; +use crate::node_selection_dialog::NodeSelectionDialog; +use nodebox_core::node::{Connection, PortType}; +use crate::parameter_panel::ParameterPanel; +use crate::render_worker::{RenderResult, RenderState, RenderWorkerHandle}; +use crate::state::AppState; +use crate::theme; +use crate::viewer_pane::{HandleResult, ViewerPane}; + +/// The main NodeBox application. +pub struct NodeBoxApp { + /// Platform for platform-abstracted file operations. + port: Arc, + /// Project context for the current project (tracks save location). + project_context: ProjectContext, + state: AppState, + address_bar: AddressBar, + viewer_pane: ViewerPane, + network_view: NetworkView, + parameters: ParameterPanel, + animation_bar: AnimationBar, + node_dialog: NodeSelectionDialog, + /// Shared icon cache for the node selection dialog. + icon_cache: IconCache, + history: History, + /// Previous library state for detecting changes. + previous_library_hash: u64, + /// Snapshot of the library at end of last frame, used for undo (pre-mutation state). + previous_library: Arc, + /// Snapshot of the selection at end of last frame, used for undo (pre-mutation state). + previous_selection: SelectionSnapshot, + /// Background render worker. + render_worker: RenderWorkerHandle, + /// State tracking for render requests. + render_state: RenderState, + /// Whether a render is pending (needs to be dispatched). + render_pending: bool, + /// Native menu handle for macOS system menu bar. + native_menu: Option, + /// Recent files list for "Open Recent" menu. + recent_files: RecentFiles, + /// Pending connection to create after the node dialog selects a node. + /// Stores (from_node_name, output_type) from a drag-to-empty-space action. + pending_connection: Option<(String, PortType)>, + /// Whether any component was dragging in the previous frame (for undo group detection). + was_dragging: bool, + /// Vertical split ratio between Parameters (top) and Network (bottom). Default 0.35. + right_panel_split: f32, +} + +impl NodeBoxApp { + /// Create a new NodeBox application instance with a Platform for file operations. + /// + /// This is the primary constructor that accepts an `Arc` for + /// platform-abstracted file operations. + pub fn new_with_port( + cc: &eframe::CreationContext<'_>, + port: Arc, + initial_file: Option, + ) -> Self { + // Configure the global theme/style + theme::configure_style(&cc.egui_ctx); + + // Initialize native menu bar (macOS) + let native_menu = Some(NativeMenuHandle::new()); + + let mut state = AppState::new(); + + // Load recent files from disk + let mut recent_files = RecentFiles::load(); + + // Determine project context from initial file + let project_context = if let Some(ref path) = initial_file { + if let Err(e) = state.load_file(path) { + log::error!("Failed to load initial file {:?}: {}", path, e); + ProjectContext::new_unsaved() + } else { + // Add to recent files on successful load + recent_files.add_file(path.clone()); + recent_files.save(); + + // Create project context from file path + if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) { + ProjectContext::new(parent, file_name.to_string_lossy().to_string()) + } else { + ProjectContext::new_unsaved() + } + } + } else { + ProjectContext::new_unsaved() + }; + + // Rebuild native menu with recent files + if let Some(ref menu) = native_menu { + menu.rebuild_recent_menu(&recent_files.files()); + } + + let hash = Self::hash_library(&state.library); + let prev_library = Arc::clone(&state.library); + Self { + port, + project_context, + state, + address_bar: AddressBar::new(), + viewer_pane: ViewerPane::new(), + network_view: NetworkView::new(), + parameters: ParameterPanel::new(), + animation_bar: AnimationBar::new(), + node_dialog: NodeSelectionDialog::new(), + icon_cache: IconCache::new(), + history: History::new(), + previous_library_hash: hash, + previous_library: prev_library, + previous_selection: SelectionSnapshot::default(), + render_worker: RenderWorkerHandle::spawn(), + render_state: RenderState::new(), + render_pending: true, + native_menu, + recent_files, + pending_connection: None, + was_dragging: false, + right_panel_split: 0.35, + } + } + + /// Create a new NodeBox application instance (legacy constructor). + /// + /// This constructor creates a DesktopPlatform internally for backwards compatibility. + /// Prefer using `new_with_port` for new code. + #[allow(dead_code)] + pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { + Self::new_with_file(_cc, None, None) + } + + /// Create a new NodeBox application instance, optionally loading an initial file. + /// + /// This constructor creates a DesktopPlatform internally for backwards compatibility. + /// Prefer using `new_with_port` for new code. + pub fn new_with_file( + cc: &eframe::CreationContext<'_>, + initial_file: Option, + native_menu: Option, + ) -> Self { + // Configure the global theme/style + theme::configure_style(&cc.egui_ctx); + + // Create a default DesktopPlatform for backwards compatibility + #[cfg(not(target_arch = "wasm32"))] + let port: Arc = Arc::new(crate::DesktopPlatform::new()); + #[cfg(target_arch = "wasm32")] + compile_error!("WASM builds must use new_with_port with a custom Platform implementation"); + + let mut state = AppState::new(); + + // Load recent files from disk + let mut recent_files = RecentFiles::load(); + + // Determine project context from initial file + let project_context = if let Some(ref path) = initial_file { + if let Err(e) = state.load_file(path) { + log::error!("Failed to load initial file {:?}: {}", path, e); + ProjectContext::new_unsaved() + } else { + // Add to recent files on successful load + recent_files.add_file(path.clone()); + recent_files.save(); + + // Create project context from file path + if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) { + ProjectContext::new(parent, file_name.to_string_lossy().to_string()) + } else { + ProjectContext::new_unsaved() + } + } + } else { + ProjectContext::new_unsaved() + }; + + // Rebuild native menu with recent files + if let Some(ref menu) = native_menu { + menu.rebuild_recent_menu(&recent_files.files()); + } + + let hash = Self::hash_library(&state.library); + let prev_library = Arc::clone(&state.library); + Self { + port, + project_context, + state, + address_bar: AddressBar::new(), + viewer_pane: ViewerPane::new(), + network_view: NetworkView::new(), + parameters: ParameterPanel::new(), + animation_bar: AnimationBar::new(), + node_dialog: NodeSelectionDialog::new(), + icon_cache: IconCache::new(), + history: History::new(), + previous_library_hash: hash, + previous_library: prev_library, + previous_selection: SelectionSnapshot::default(), + render_worker: RenderWorkerHandle::spawn(), + render_state: RenderState::new(), + render_pending: true, + native_menu, + recent_files, + pending_connection: None, + was_dragging: false, + right_panel_split: 0.35, + } + } + + /// Create a new NodeBox application instance for testing. + /// + /// This constructor creates an app without spawning a render worker thread, + /// making it suitable for unit tests and integration tests. + #[cfg(test)] + #[allow(dead_code)] + pub fn new_for_testing() -> Self { + let state = AppState::new(); + let hash = Self::hash_library(&state.library); + let prev_library = Arc::clone(&state.library); + Self { + port: Arc::new(crate::DesktopPlatform::new()), + project_context: ProjectContext::new_unsaved(), + state, + address_bar: AddressBar::new(), + viewer_pane: ViewerPane::new(), + network_view: NetworkView::new(), + parameters: ParameterPanel::new(), + animation_bar: AnimationBar::new(), + node_dialog: NodeSelectionDialog::new(), + icon_cache: IconCache::new(), + history: History::new(), + previous_library_hash: hash, + previous_library: prev_library, + previous_selection: SelectionSnapshot::default(), + render_worker: RenderWorkerHandle::spawn(), + render_state: RenderState::new(), + render_pending: false, + native_menu: None, + recent_files: RecentFiles::new(), + pending_connection: None, + was_dragging: false, + right_panel_split: 0.35, + } + } + + /// Create a new NodeBox application instance for testing with an empty library. + /// + /// This is useful for tests that need to set up their own node configuration. + #[cfg(test)] + #[allow(dead_code)] + pub fn new_for_testing_empty() -> Self { + let mut state = AppState::new(); + state.library = Arc::new(nodebox_core::node::NodeLibrary::new("test")); + state.geometry.clear(); + let hash = Self::hash_library(&state.library); + let prev_library = Arc::clone(&state.library); + Self { + port: Arc::new(crate::DesktopPlatform::new()), + project_context: ProjectContext::new_unsaved(), + state, + address_bar: AddressBar::new(), + viewer_pane: ViewerPane::new(), + network_view: NetworkView::new(), + parameters: ParameterPanel::new(), + animation_bar: AnimationBar::new(), + node_dialog: NodeSelectionDialog::new(), + icon_cache: IconCache::new(), + history: History::new(), + previous_library_hash: hash, + previous_library: prev_library, + previous_selection: SelectionSnapshot::default(), + render_worker: RenderWorkerHandle::spawn(), + render_state: RenderState::new(), + render_pending: false, + native_menu: None, + recent_files: RecentFiles::new(), + pending_connection: None, + was_dragging: false, + right_panel_split: 0.35, + } + } + + /// Get a reference to the application state. + #[allow(dead_code)] + pub fn state(&self) -> &AppState { + &self.state + } + + /// Get a mutable reference to the application state. + #[allow(dead_code)] + pub fn state_mut(&mut self) -> &mut AppState { + &mut self.state + } + + /// Get a reference to the history manager. + #[allow(dead_code)] + pub fn history(&self) -> &History { + &self.history + } + + /// Get a mutable reference to the history manager. + #[allow(dead_code)] + pub fn history_mut(&mut self) -> &mut History { + &mut self.history + } + + /// Get a reference to the Platform for file operations. + #[allow(dead_code)] + pub fn port(&self) -> &Arc { + &self.port + } + + /// Get a reference to the project context. + #[allow(dead_code)] + pub fn project_context(&self) -> &ProjectContext { + &self.project_context + } + + /// Synchronously evaluate the network for testing. + /// + /// Unlike the normal async flow, this directly evaluates and updates geometry. + #[cfg(test)] + #[allow(dead_code)] + pub fn evaluate_for_testing(&mut self) { + let (geometry, output, errors) = crate::eval::evaluate_network( + &self.state.library, + &self.port, + &self.project_context, + ); + if errors.is_empty() { + self.state.geometry = geometry; + self.state.node_output = output; + self.state.node_errors.clear(); + } else { + self.state.node_errors = errors + .into_iter() + .map(|e| (e.node_name, e.message)) + .collect(); + } + } + + /// Simulate a frame update for testing purposes. + /// + /// This checks for changes and updates history, similar to what happens + /// during a normal frame update, but without the async render worker. + #[cfg(test)] + #[allow(dead_code)] + pub fn update_for_testing(&mut self) { + // Check for changes and save to history (using previous_library for correct undo) + let current_hash = Self::hash_library(&self.state.library); + if current_hash != self.previous_library_hash { + self.history.save_state(&self.previous_library, &self.previous_selection); + self.previous_library_hash = current_hash; + if !self.history.is_in_group() { + self.previous_library = Arc::clone(&self.state.library); + self.previous_selection = self.current_selection(); + } + self.state.dirty = true; + } + // Synchronously evaluate using the app's port + self.evaluate_for_testing(); + } + + /// Compute a simple hash of the library for change detection. + fn hash_library(library: &nodebox_core::node::NodeLibrary) -> u64 { + use std::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let mut hasher = DefaultHasher::new(); + + // Hash the number of children and their names/positions + library.root.children.len().hash(&mut hasher); + for child in &library.root.children { + child.name.hash(&mut hasher); + (child.position.x as i64).hash(&mut hasher); + (child.position.y as i64).hash(&mut hasher); + child.inputs.len().hash(&mut hasher); + + // Hash port values + for port in &child.inputs { + port.name.hash(&mut hasher); + // Hash the value - convert to string representation for simplicity + format!("{:?}", port.value).hash(&mut hasher); + } + } + + // Hash connections + library.root.connections.len().hash(&mut hasher); + for conn in &library.root.connections { + conn.output_node.hash(&mut hasher); + conn.input_node.hash(&mut hasher); + conn.input_port.hash(&mut hasher); + } + + // Hash rendered child + library.root.rendered_child.hash(&mut hasher); + + hasher.finish() + } + + /// Poll for render results and dispatch pending renders. + fn poll_render_results(&mut self) { + // Check for completed renders + while let Some(result) = self.render_worker.try_recv_result() { + match result { + RenderResult::Success { id, geometry, output, errors } => { + if self.render_state.is_current(id) { + if errors.is_empty() { + // Success with no errors: update geometry and clear errors + self.state.geometry = geometry; + self.state.node_output = output; + self.state.node_errors.clear(); + } else { + // Success with errors: keep last geometry, populate errors + self.state.node_errors = errors + .into_iter() + .map(|e| (e.node_name, e.message)) + .collect(); + } + self.render_state.complete(); + } + } + RenderResult::Cancelled { id } => { + if self.render_state.is_current(id) { + // Keep previous geometry visible + self.render_state.complete(); + } + } + RenderResult::Error { id, message } => { + if self.render_state.is_current(id) { + log::error!("Render error: {}", message); + // Keep last geometry on complete failure + self.render_state.complete(); + } + } + } + } + + // Dispatch pending render if not already rendering + if self.render_pending && !self.render_state.is_rendering { + let (id, cancel_token) = self.render_state.dispatch_new(); + // Update the frame number from the animation bar before rendering + self.project_context.frame = self.animation_bar.frame(); + self.render_worker.request_render( + id, + Arc::clone(&self.state.library), + cancel_token, + self.port.clone(), + self.project_context.clone(), + ); + self.render_pending = false; + } + } + + /// Cancel the current render operation. + fn cancel_render(&mut self) { + if self.render_state.is_rendering { + self.render_state.cancel(); + } + } + + /// Build a snapshot of the current selection state. + fn current_selection(&self) -> SelectionSnapshot { + SelectionSnapshot { + selected_nodes: self.network_view.selected_nodes().clone(), + selected_node: self.state.selected_node.clone(), + } + } + + /// Apply a restored selection snapshot, validating that referenced nodes + /// still exist in the current library (prunes stale names). + fn apply_selection(&mut self, snapshot: SelectionSnapshot) { + let valid_selected: std::collections::HashSet = snapshot + .selected_nodes + .into_iter() + .filter(|name| self.state.library.root.child(name).is_some()) + .collect(); + let valid_selected_node = snapshot + .selected_node + .filter(|name| self.state.library.root.child(name).is_some()); + self.network_view.set_selected(valid_selected); + self.state.selected_node = valid_selected_node; + } + + /// Check for changes and save to history, queue render if needed. + /// + /// Saves the *previous* library state (from before this frame's mutations) + /// so that undo restores to the pre-mutation state. + fn check_for_changes(&mut self) { + let current_hash = Self::hash_library(&self.state.library); + if current_hash != self.previous_library_hash { + self.history.save_state(&self.previous_library, &self.previous_selection); + self.previous_library_hash = current_hash; + // Only update previous_library/selection when not in an undo group. + // During a group, the group_start_state tracks the pre-drag snapshot + // and previous state must stay frozen so that the next non-grouped + // change still records the correct pre-mutation state. + if !self.history.is_in_group() { + self.previous_library = Arc::clone(&self.state.library); + self.previous_selection = self.current_selection(); + } + self.state.dirty = true; + self.render_pending = true; // Queue async render + } + } + + /// Handle a menu action from the native menu bar. + fn handle_menu_action(&mut self, action: MenuAction, ctx: &egui::Context) { + match action { + MenuAction::New => self.state.new_document(), + MenuAction::Open => self.open_file(), + MenuAction::OpenRecent(path) => self.open_recent_file(&path), + MenuAction::ClearRecent => self.clear_recent_files(), + MenuAction::Save => self.save_file(), + MenuAction::SaveAs => self.save_file_as(), + MenuAction::ExportPng => self.export_png(), + MenuAction::ExportSvg => self.export_svg(), + MenuAction::Undo => { + let sel = self.current_selection(); + if let Some((previous, prev_sel)) = self.history.undo(&self.state.library, &sel) { + self.state.library = previous; + self.previous_library_hash = Self::hash_library(&self.state.library); + self.previous_library = Arc::clone(&self.state.library); + self.apply_selection(prev_sel); + self.previous_selection = self.current_selection(); + self.render_pending = true; + } + } + MenuAction::Redo => { + let sel = self.current_selection(); + if let Some((next, next_sel)) = self.history.redo(&self.state.library, &sel) { + self.state.library = next; + self.previous_library_hash = Self::hash_library(&self.state.library); + self.previous_library = Arc::clone(&self.state.library); + self.apply_selection(next_sel); + self.previous_selection = self.current_selection(); + self.render_pending = true; + } + } + MenuAction::ZoomIn => self.viewer_pane.zoom_in(), + MenuAction::ZoomOut => self.viewer_pane.zoom_out(), + MenuAction::ZoomReset => self.viewer_pane.reset_zoom(), + MenuAction::About => self.state.show_about = true, + // Clipboard actions handled by system + MenuAction::Cut | MenuAction::Copy | MenuAction::Paste | + MenuAction::Delete | MenuAction::SelectAll => {} + } + ctx.request_repaint(); + } + + /// Show the menu bar. + #[cfg(not(target_os = "macos"))] + fn show_menu_bar(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + // Collect recent files to avoid borrow issues + let recent_files_list = self.recent_files.files(); + + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("File", |ui| { + if ui.button("New").clicked() { + self.state.new_document(); + ui.close(); + } + if ui.button("Open...").clicked() { + self.open_file(); + ui.close(); + } + ui.menu_button("Open Recent", |ui| { + if recent_files_list.is_empty() { + ui.label("No recent files"); + } else { + // Store the path to open (if any) to avoid borrow issues + let mut path_to_open = None; + for path in &recent_files_list { + let display_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown"); + if ui.button(display_name).clicked() { + path_to_open = Some(path.clone()); + ui.close(); + } + } + if let Some(path) = path_to_open { + self.open_recent_file(&path); + } + ui.separator(); + } + if ui.add_enabled(!recent_files_list.is_empty(), egui::Button::new("Clear Recent")).clicked() { + self.clear_recent_files(); + ui.close(); + } + }); + if ui.button("Save").clicked() { + self.save_file(); + ui.close(); + } + if ui.button("Save As...").clicked() { + self.save_file_as(); + ui.close(); + } + ui.separator(); + if ui.button("Export SVG...").clicked() { + self.export_svg(); + ui.close(); + } + if ui.button("Export PNG...").clicked() { + self.export_png(); + ui.close(); + } + ui.separator(); + if ui.button("Quit").clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + + ui.menu_button("Edit", |ui| { + let undo_text = if self.history.can_undo() { + format!("Undo ({})", self.history.undo_count()) + } else { + "Undo".to_string() + }; + if ui.add_enabled(self.history.can_undo(), egui::Button::new(undo_text)).clicked() { + let sel = self.current_selection(); + if let Some((previous, prev_sel)) = self.history.undo(&self.state.library, &sel) { + self.state.library = previous; + self.previous_library_hash = Self::hash_library(&self.state.library); + self.previous_library = Arc::clone(&self.state.library); + self.apply_selection(prev_sel); + self.previous_selection = self.current_selection(); + self.render_pending = true; + } + ui.close(); + } + let redo_text = if self.history.can_redo() { + format!("Redo ({})", self.history.redo_count()) + } else { + "Redo".to_string() + }; + if ui.add_enabled(self.history.can_redo(), egui::Button::new(redo_text)).clicked() { + let sel = self.current_selection(); + if let Some((next, next_sel)) = self.history.redo(&self.state.library, &sel) { + self.state.library = next; + self.previous_library_hash = Self::hash_library(&self.state.library); + self.previous_library = Arc::clone(&self.state.library); + self.apply_selection(next_sel); + self.previous_selection = self.current_selection(); + self.render_pending = true; + } + ui.close(); + } + ui.separator(); + if ui.button("Delete Selected").clicked() { + ui.close(); + } + }); + + ui.menu_button("View", |ui| { + if ui.button("Zoom In").clicked() { + self.viewer_pane.zoom_in(); + ui.close(); + } + if ui.button("Zoom Out").clicked() { + self.viewer_pane.zoom_out(); + ui.close(); + } + if ui.button("Fit to Window").clicked() { + self.viewer_pane.fit_to_window(); + ui.close(); + } + ui.separator(); + ui.checkbox(&mut self.viewer_pane.show_handles, "Show Handles"); + ui.checkbox(&mut self.viewer_pane.show_points, "Show Points"); + ui.checkbox(&mut self.viewer_pane.show_origin, "Show Origin"); + ui.checkbox(&mut self.viewer_pane.show_canvas_border, "Show Canvas"); + }); + + ui.menu_button("Help", |ui| { + if ui.button("About NodeBox").clicked() { + self.state.show_about = true; + ui.close(); + } + }); + }); + } +} + +impl eframe::App for NodeBoxApp { + #[allow(unused_variables)] + fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + // Poll for native menu events (macOS system menu bar) + if let Some(ref native_menu) = self.native_menu { + if let Some(action) = native_menu.poll_event() { + self.handle_menu_action(action, ctx); + } + } + + // Poll for background render results + self.poll_render_results(); + + // Request repaint while rendering is in progress + if self.render_state.is_rendering || self.render_pending { + ctx.request_repaint(); + } + + // 1. Menu bar (top-most) - only show in-window menu on non-macOS platforms + #[cfg(not(target_os = "macos"))] + egui::TopBottomPanel::top("menu_bar") + .frame(egui::Frame::NONE.fill(theme::PANEL_BG)) + .show(ctx, |ui| { + self.show_menu_bar(ui, ctx); + }); + + // 2. Address bar (below menu) - frameless, handles its own styling + egui::TopBottomPanel::top("address_bar") + .exact_height(theme::ADDRESS_BAR_HEIGHT) + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + // Update render state for stop button + let elapsed_secs = self.render_state.elapsed() + .map(|d| d.as_secs_f32()) + .unwrap_or(0.0); + self.address_bar.set_render_state( + self.render_state.is_rendering, + elapsed_secs, + ); + + match self.address_bar.show(ui) { + AddressBarAction::NavigateTo(_path) => { + // Future: navigate to sub-network + } + AddressBarAction::StopClicked => { + self.cancel_render(); + } + AddressBarAction::None => {} + } + }); + + // 2b. Notification banners (below address bar, only shown when notifications exist) + if !self.state.notifications.is_empty() { + let banner_height = notification_banner::BANNER_HEIGHT + * self.state.notifications.len() as f32; + egui::TopBottomPanel::top("notification_banners") + .exact_height(banner_height) + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let notifs: Vec<_> = self.state.notifications + .iter() + .map(|n| (n.id, n.message.clone(), n.level.clone())) + .collect(); + + let dismissed = notification_banner::show_notifications(ui, ¬ifs); + + for id in dismissed { + self.state.dismiss_notification(id); + } + }); + } + + // 3. Animation bar (bottom) - frameless, handles its own styling + let anim_response = egui::TopBottomPanel::bottom("animation_bar") + .exact_height(theme::ANIMATION_BAR_HEIGHT) + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + self.animation_bar.show(ui) + }); + match anim_response.inner { + AnimationEvent::FrameChanged(_) + | AnimationEvent::Rewind + | AnimationEvent::Stop => { + self.render_pending = true; + } + _ => {} + } + + // Update animation playback + if self.animation_bar.is_playing() { + if self.animation_bar.update() { + self.render_pending = true; + } + ctx.request_repaint(); + } + + // Capture pre-frame library state and selection for undo group detection. + // These are cheap clones (Arc pointer + small HashSet). + let pre_frame_library = Arc::clone(&self.state.library); + let pre_frame_selection = self.current_selection(); + + // 4. Right side panel containing Parameters (top) and Network (bottom) + // + // Style the built-in separator: egui uses noninteractive.bg_stroke (normal), + // hovered.fg_stroke (hover), and active.fg_stroke (dragging). + ctx.style_mut(|style| { + style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(2.0, theme::ZINC_900); + style.visuals.widgets.hovered.fg_stroke = egui::Stroke::new(2.0, theme::ZINC_700); + style.visuals.widgets.active.fg_stroke = egui::Stroke::new(2.0, theme::ZINC_300); + }); + + egui::SidePanel::right("right_panel") + .default_width(450.0) + .min_width(300.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(theme::PANEL_BG)) + .show(ctx, |ui| { + // Remove default spacing to have tighter control + ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); + + let available = ui.available_rect_before_wrap(); + + // Enforce minimum heights: each panel gets at least 80px + let min_panel_height = 80.0_f32; + let min_ratio = min_panel_height / available.height(); + let max_ratio = 1.0 - min_ratio; + self.right_panel_split = self.right_panel_split.clamp(min_ratio, max_ratio); + + let split_y = available.min.y + available.height() * self.right_panel_split; + + // Top: Parameters pane + let params_rect = Rect::from_min_max( + available.min, + Pos2::new(available.max.x, split_y), + ); + + ui.scope_builder(egui::UiBuilder::new().max_rect(params_rect), |ui| { + ui.set_clip_rect(params_rect); + self.parameters + .show(ui, &mut self.state, self.port.as_ref(), &self.project_context); + }); + + // Horizontal splitter between Parameters and Network. + // The interaction zone overlaps both panels so there is no visible gap. + let half = theme::SPLITTER_AFFORDANCE / 2.0; + let splitter_rect = Rect::from_min_max( + Pos2::new(available.min.x, split_y - half), + Pos2::new(available.max.x, split_y + half), + ); + + let splitter_id = ui.id().with("params_network_splitter"); + let splitter_response = ui.interact(splitter_rect, splitter_id, egui::Sense::drag()); + + let is_active = splitter_response.dragged(); + let is_hovered = splitter_response.hovered(); + + // Draw splitter line at the boundary + let stroke_color = if is_active { + theme::ZINC_300 + } else if is_hovered { + theme::ZINC_400 + } else { + theme::ZINC_600 + }; + ui.painter().line_segment( + [ + Pos2::new(available.min.x, split_y), + Pos2::new(available.max.x, split_y), + ], + egui::Stroke::new(theme::SPLITTER_THICKNESS, stroke_color), + ); + + if is_hovered || is_active { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + + if splitter_response.dragged() { + if let Some(pointer_pos) = ui.ctx().pointer_latest_pos() { + let new_ratio = (pointer_pos.y - available.min.y) / available.height(); + self.right_panel_split = new_ratio.clamp(min_ratio, max_ratio); + } + } + + // Bottom: Network pane + let network_rect = Rect::from_min_max( + Pos2::new(available.min.x, split_y), + available.max, + ); + + ui.scope_builder(egui::UiBuilder::new().max_rect(network_rect), |ui| { + ui.set_clip_rect(network_rect); + + // Network header with "+ New Node" button + let (header_rect, x) = components::draw_pane_header_with_title(ui, "Network"); + + // "+ New Node" button after the separator + let (clicked, _) = components::header_text_button( + ui, + header_rect, + x, + "+ New Node", + 70.0, + ); + + if clicked { + self.node_dialog.open(Point::new(0.0, 0.0)); + } + + // Network view + let action = self.network_view.show(ui, &mut self.state.library, &self.state.node_errors); + + // Handle network actions + match action { + NetworkAction::OpenNodeDialog(pos) => { + self.node_dialog.open(pos); + } + NetworkAction::OpenNodeDialogForConnection { position, from_node, output_type } => { + self.node_dialog.open_for_connection(position, output_type.clone()); + self.pending_connection = Some((from_node, output_type)); + } + NetworkAction::None => {} + } + + // Update selected node from network view + let selected = self.network_view.selected_nodes(); + if selected.len() == 1 { + self.state.selected_node = selected.iter().next().cloned(); + } else if selected.is_empty() { + self.state.selected_node = None; + } else if !self.state.selected_node.as_ref().is_some_and(|n| selected.contains(n)) { + // Multiple selected but current isn't among them (e.g., after alt-drag copy) + self.state.selected_node = selected.iter().next().cloned(); + } + }); + }); + + // Restore widget strokes for the rest of the UI + ctx.style_mut(|style| { + style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::NONE; + style.visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme::ZINC_50); + style.visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, theme::ZINC_50); + }); + + // 5. Central panel: Viewer (left side, takes remaining space) - clean frame + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(theme::PANEL_BG)) + .show(ctx, |ui| { + // Update handles for selected node + self.viewer_pane.update_handles_for_node( + self.state.selected_node.as_deref(), + &self.state, + ); + + // Show viewer and handle interactions + // Get wgpu render state for GPU-accelerated rendering (when available) + #[cfg(feature = "gpu-rendering")] + let render_state = frame.wgpu_render_state(); + #[cfg(not(feature = "gpu-rendering"))] + let render_state: Option<&crate::viewer_pane::RenderState> = None; + + let result = self.viewer_pane.show(ui, &self.state, render_state); + match result { + HandleResult::PointChange { param, value } => { + self.handle_parameter_change(¶m, value); + self.render_pending = true; + } + HandleResult::FourPointChange { x, y, width, height } => { + self.handle_four_point_change(x, y, width, height); + self.render_pending = true; + } + HandleResult::StringChange { param, value } => { + self.handle_string_change(¶m, &value); + self.render_pending = true; + } + HandleResult::None => {} + } + }); + + // 6. Node selection dialog + if self.node_dialog.visible { + if let Some(new_node) = self.node_dialog.show(ctx, &self.state.library, &mut self.icon_cache) { + let node_name = new_node.name.clone(); + + // Find the first compatible input port for any pending connection + let connection_to_create = self.pending_connection.take().and_then(|(from_node, output_type)| { + new_node.inputs.iter() + .find(|p| PortType::is_compatible(&output_type, &p.port_type)) + .map(|p| (from_node, p.name.clone())) + }); + + Arc::make_mut(&mut self.state.library).root.children.push(new_node); + self.state.selected_node = Some(node_name.clone()); + + // Create the auto-connection if drag-to-create was used + if let Some((from_node, port_name)) = connection_to_create { + Arc::make_mut(&mut self.state.library) + .root + .connect(Connection::new(from_node, node_name, port_name)); + } + } + } + + // Clear pending connection if dialog was dismissed + if !self.node_dialog.visible { + self.pending_connection = None; + } + + // 7. About dialog + if self.state.show_about { + egui::Window::new("About NodeBox") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.heading("NodeBox"); + ui.label("Version 4.0 (Rust)"); + ui.add_space(10.0); + ui.label("A node-based generative design tool"); + ui.add_space(10.0); + ui.hyperlink_to("Visit website", "https://www.nodebox.net"); + ui.add_space(10.0); + if ui.button("Close").clicked() { + self.state.show_about = false; + } + }); + }); + } + + // Handle keyboard shortcuts + let (do_undo, do_redo, do_cancel) = ctx.input(|i| { + let undo = i.modifiers.command && i.key_pressed(egui::Key::Z) && !i.modifiers.shift; + let redo = (i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::Z)) + || (i.modifiers.command && i.key_pressed(egui::Key::Y)); + // Cmd/Ctrl + . to cancel rendering + let cancel = i.modifiers.command && i.key_pressed(egui::Key::Period); + (undo, redo, cancel) + }); + + if do_cancel { + self.cancel_render(); + } + + if do_undo { + let sel = self.current_selection(); + if let Some((previous, prev_sel)) = self.history.undo(&self.state.library, &sel) { + self.state.library = previous; + self.previous_library_hash = Self::hash_library(&self.state.library); + self.previous_library = Arc::clone(&self.state.library); + self.apply_selection(prev_sel); + self.previous_selection = self.current_selection(); + self.render_pending = true; + } + } + if do_redo { + let sel = self.current_selection(); + if let Some((next, next_sel)) = self.history.redo(&self.state.library, &sel) { + self.state.library = next; + self.previous_library_hash = Self::hash_library(&self.state.library); + self.previous_library = Arc::clone(&self.state.library); + self.apply_selection(next_sel); + self.previous_selection = self.current_selection(); + self.render_pending = true; + } + } + + // Detect drag transitions for undo grouping. + // When a drag starts, begin an undo group so all intermediate changes + // are collapsed into a single undo entry. When the drag ends, close the group. + let is_dragging = self.viewer_pane.is_dragging() + || self.network_view.is_dragging_nodes() + || self.parameters.is_dragging(); + + if is_dragging && !self.was_dragging { + // Drag just started — begin undo group with the pre-frame state + // (captured before any panel mutations this frame). + self.history.begin_undo_group(&pre_frame_library, &pre_frame_selection); + } + if !is_dragging && self.was_dragging { + // Drag just ended — close the group (creates a single undo entry). + self.history.end_undo_group(&self.state.library); + // Update hash and snapshot so check_for_changes doesn't create a duplicate entry. + self.previous_library_hash = Self::hash_library(&self.state.library); + self.previous_library = Arc::clone(&self.state.library); + self.previous_selection = self.current_selection(); + } + self.was_dragging = is_dragging; + + // Check for state changes and save to history + self.check_for_changes(); + + // Request repaint if a change was detected (ensures next frame runs to dispatch render) + if self.render_pending { + ctx.request_repaint(); + } + } +} + +impl NodeBoxApp { + /// Handle FourPointHandle change (rect x, y, width, height). + fn handle_four_point_change(&mut self, x: f64, y: f64, width: f64, height: f64) { + if let Some(ref node_name) = self.state.selected_node { + if let Some(node) = Arc::make_mut(&mut self.state.library).root.child_mut(node_name) { + // Write to "position" Point port (per corevector.ndbx) + if let Some(port) = node.input_mut("position") { + port.value = nodebox_core::Value::Point(Point::new(x, y)); + } + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(width); + } + if let Some(port) = node.input_mut("height") { + port.value = nodebox_core::Value::Float(height); + } + } + } + } + + /// Handle string parameter change from freehand handle. + fn handle_string_change(&mut self, param_name: &str, value: &str) { + if let Some(ref node_name) = self.state.selected_node { + if let Some(node) = Arc::make_mut(&mut self.state.library).root.child_mut(node_name) { + if let Some(port) = node.input_mut(param_name) { + port.value = nodebox_core::Value::String(value.to_string()); + } + } + } + } + + /// Handle parameter change from viewer handles. + fn handle_parameter_change(&mut self, param_name: &str, new_position: Point) { + if let Some(ref node_name) = self.state.selected_node { + if let Some(node) = Arc::make_mut(&mut self.state.library).root.child_mut(node_name) { + match param_name { + "position" => { + // Write to "position" Point port (per corevector.ndbx) + if let Some(port) = node.input_mut("position") { + port.value = nodebox_core::Value::Point(new_position); + } + } + "width" => { + // Get center from position port + let center_x = node.input("position") + .and_then(|p| p.value.as_point().cloned()) + .map(|p| p.x) + .unwrap_or(0.0); + let new_width = (new_position.x - center_x) * 2.0; + if let Some(width_port) = node.input_mut("width") { + width_port.value = nodebox_core::Value::Float(new_width.abs()); + } + } + "height" => { + // Get center from position port + let center_y = node.input("position") + .and_then(|p| p.value.as_point().cloned()) + .map(|p| p.y) + .unwrap_or(0.0); + let new_height = (new_position.y - center_y) * 2.0; + if let Some(height_port) = node.input_mut("height") { + height_port.value = nodebox_core::Value::Float(new_height.abs()); + } + } + "size" => { + // Get center from position port + let center = node.input("position") + .and_then(|p| p.value.as_point().cloned()) + .unwrap_or(Point::ZERO); + if let Some(width_port) = node.input_mut("width") { + width_port.value = nodebox_core::Value::Float((new_position.x - center.x).abs()); + } + if let Some(height_port) = node.input_mut("height") { + height_port.value = nodebox_core::Value::Float((new_position.y - center.y).abs()); + } + } + "point1" | "point2" => { + if let Some(port) = node.input_mut(param_name) { + port.value = nodebox_core::Value::Point(new_position); + } + } + _ => {} + } + } + } + } + + fn open_file(&mut self) { + use nodebox_core::platform::FileFilter; + + match self.port.show_open_project_dialog(&[FileFilter::nodebox()]) { + Ok(Some(path)) => { + if let Err(e) = self.state.load_file(&path) { + log::error!("Failed to load file: {}", e); + } else { + // Update project context with new file location + if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) { + self.project_context = + ProjectContext::new(parent, file_name.to_string_lossy().to_string()); + } + self.add_to_recent_files(path); + } + } + Ok(None) => {} // User cancelled + Err(e) => log::error!("Failed to open file dialog: {}", e), + } + } + + /// Open a file from the recent files list. + fn open_recent_file(&mut self, path: &std::path::Path) { + if let Err(e) = self.state.load_file(path) { + log::error!("Failed to load recent file: {}", e); + } else { + // Update project context with new file location + if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) { + self.project_context = + ProjectContext::new(parent, file_name.to_string_lossy().to_string()); + } + self.add_to_recent_files(path.to_path_buf()); + } + } + + /// Add a file to the recent files list and update the menu. + fn add_to_recent_files(&mut self, path: std::path::PathBuf) { + self.recent_files.add_file(path); + self.recent_files.save(); + if let Some(ref menu) = self.native_menu { + menu.rebuild_recent_menu(&self.recent_files.files()); + } + } + + /// Clear all recent files. + fn clear_recent_files(&mut self) { + self.recent_files.clear(); + self.recent_files.save(); + if let Some(ref menu) = self.native_menu { + menu.rebuild_recent_menu(&self.recent_files.files()); + } + } + + fn save_file(&mut self) { + if let Some(ref path) = self.state.current_file.clone() { + if let Err(e) = self.state.save_file(path) { + log::error!("Failed to save file: {}", e); + } + } else { + self.save_file_as(); + } + } + + fn save_file_as(&mut self) { + use nodebox_core::platform::FileFilter; + + match self.port.show_save_project_dialog(&[FileFilter::nodebox()], Some("untitled.ndbx")) { + Ok(Some(path)) => { + if let Err(e) = self.state.save_file(&path) { + log::error!("Failed to save file: {}", e); + } else { + // Update project context with new file location + if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) { + self.project_context = + ProjectContext::new(parent, file_name.to_string_lossy().to_string()); + } + self.add_to_recent_files(path); + } + } + Ok(None) => {} // User cancelled + Err(e) => log::error!("Failed to open save dialog: {}", e), + } + } + + fn export_svg(&mut self) { + use nodebox_core::platform::FileFilter; + + // Export dialogs use save_project_dialog since exports are not sandboxed + match self.port.show_save_project_dialog(&[FileFilter::svg()], Some("export.svg")) { + Ok(Some(path)) => { + // Use document dimensions for export + let width = self.state.library.width(); + let height = self.state.library.height(); + if let Err(e) = self.state.export_svg(&path, width, height) { + log::error!("Failed to export SVG: {}", e); + } + } + Ok(None) => {} // User cancelled + Err(e) => log::error!("Failed to open export dialog: {}", e), + } + } + + fn export_png(&mut self) { + use nodebox_core::platform::FileFilter; + + // Export dialogs use save_project_dialog since exports are not sandboxed + match self.port.show_save_project_dialog(&[FileFilter::png()], Some("export.png")) { + Ok(Some(path)) => { + // Use document dimensions for export + let width = self.state.library.width() as u32; + let height = self.state.library.height() as u32; + + if let Err(e) = crate::export::export_png( + &self.state.geometry, + &path, + width, + height, + self.state.background_color, + ) { + log::error!("Failed to export PNG: {}", e); + } + } + Ok(None) => {} // User cancelled + Err(e) => log::error!("Failed to open export dialog: {}", e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nodebox_core::node::{Node, Port}; + + /// Test that simulates what happens when handle_result_point_change is processed. + /// This mimics the behavior in the UI loop when HandleResult::PointChange is received. + #[test] + fn test_handle_point_change_triggers_render() { + let mut app = NodeBoxApp::new_for_testing(); + + // Set up a node with a position parameter + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("ellipse1".to_string()); + app.state.selected_node = Some("ellipse1".to_string()); + + // Reset render_pending to false + app.render_pending = false; + + // Simulate what happens in the UI loop when a handle is dragged. + // This mimics the code in the `match result` block for HandleResult::PointChange. + let result = HandleResult::PointChange { + param: "position".to_string(), + value: Point::new(50.0, 50.0), + }; + + match result { + HandleResult::PointChange { param, value } => { + app.handle_parameter_change(¶m, value); + app.render_pending = true; // This is the fix! + } + HandleResult::FourPointChange { x, y, width, height } => { + app.handle_four_point_change(x, y, width, height); + app.render_pending = true; + } + HandleResult::StringChange { param, value } => { + app.handle_string_change(¶m, &value); + app.render_pending = true; + } + HandleResult::None => {} + } + + // After the fix, render_pending should be true immediately + assert!( + app.render_pending, + "Handle change should trigger render immediately" + ); + } + + /// Test that simulates what happens when HandleResult::FourPointChange is processed. + /// This mimics the behavior in the UI loop when a rectangle handle is dragged. + #[test] + fn test_handle_four_point_change_triggers_render() { + let mut app = NodeBoxApp::new_for_testing(); + + // Set up a node with position and size parameters + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::float("x", 0.0)) + .with_input(Port::float("y", 0.0)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.state.selected_node = Some("rect1".to_string()); + + // Reset render_pending to false + app.render_pending = false; + + // Simulate what happens in the UI loop when a four-point handle is dragged. + let result = HandleResult::FourPointChange { + x: 50.0, + y: 50.0, + width: 200.0, + height: 200.0, + }; + + match result { + HandleResult::PointChange { param, value } => { + app.handle_parameter_change(¶m, value); + app.render_pending = true; + } + HandleResult::FourPointChange { x, y, width, height } => { + app.handle_four_point_change(x, y, width, height); + app.render_pending = true; // This is the fix! + } + HandleResult::StringChange { param, value } => { + app.handle_string_change(¶m, &value); + app.render_pending = true; + } + HandleResult::None => {} + } + + // After the fix, render_pending should be true immediately + assert!( + app.render_pending, + "Four-point handle change should trigger render immediately" + ); + } + + /// Test that parameter changes via check_for_changes() properly set render_pending. + /// This verifies the core fix: when a parameter is modified and check_for_changes() + /// detects the hash mismatch, render_pending should be set to true. + #[test] + fn test_parameter_change_sets_render_pending_via_check_for_changes() { + let mut app = NodeBoxApp::new_for_testing(); + + // Set up a node with a width parameter + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + + // Update the hash to match current state + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + app.render_pending = false; + + // Modify the width parameter (simulates what happens when user changes value in panel) + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(200.0); + } + } + + // Call check_for_changes (this is what the update() loop calls at the end) + app.check_for_changes(); + + // After check_for_changes detects the hash mismatch, render_pending should be true + assert!( + app.render_pending, + "check_for_changes() should set render_pending = true when parameter changes" + ); + + // Also verify the hash was updated + let new_hash = NodeBoxApp::hash_library(&app.state.library); + assert_eq!( + app.previous_library_hash, new_hash, + "previous_library_hash should be updated after check_for_changes()" + ); + } + + /// Test the full flow: parameter change → check_for_changes → evaluate → geometry update. + #[test] + fn test_parameter_change_triggers_render_and_updates_geometry() { + let mut app = NodeBoxApp::new_for_testing(); + + // Set up a rect node + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + + // Initial evaluation + app.update_for_testing(); + let initial_geometry = app.state.geometry.clone(); + + // Change width parameter + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(200.0); + } + } + + // Simulate frame update (should detect change and re-evaluate) + app.update_for_testing(); + + // Geometry should have changed + assert_ne!( + app.state.geometry, initial_geometry, + "Geometry should update after parameter change" + ); + } + + /// Test that a simulated drag gesture creates only a single undo entry. + /// This simulates the full drag lifecycle: begin group, mutate across multiple + /// frames (calling check_for_changes each time), end group. + #[test] + fn test_drag_creates_single_undo_entry() { + let mut app = NodeBoxApp::new_for_testing(); + + // Set up a node with a width parameter + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + assert_eq!(app.history.undo_count(), 0); + + // Simulate drag start: capture pre-drag state + let pre_drag_library = Arc::clone(&app.state.library); + let sel = SelectionSnapshot::default(); + app.history.begin_undo_group(&pre_drag_library, &sel); + + // Simulate 10 frames of dragging (each mutates the library) + for i in 1..=10 { + let new_width = 100.0 + (i as f64 * 10.0); + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(new_width); + } + } + // check_for_changes should NOT create undo entries during group + app.check_for_changes(); + } + + // During drag: no undo entries should have been created + assert_eq!(app.history.undo_count(), 0, "No undo entries during active group"); + + // Simulate drag end + app.history.end_undo_group(&app.state.library); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Exactly one undo entry should exist (the group) + assert_eq!(app.history.undo_count(), 1, "Drag should create exactly one undo entry"); + } + + /// Test that undoing a drag restores the pre-drag state. + #[test] + fn test_drag_undo_restores_pre_drag_state() { + let mut app = NodeBoxApp::new_for_testing(); + + // Set up a node with width=100 + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Simulate drag: width goes from 100 → 200 + let pre_drag = Arc::clone(&app.state.library); + let sel = SelectionSnapshot::default(); + app.history.begin_undo_group(&pre_drag, &sel); + + for i in 1..=10 { + let new_width = 100.0 + (i as f64 * 10.0); + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(new_width); + } + } + app.check_for_changes(); + } + + app.history.end_undo_group(&app.state.library); + + // Current width should be 200 + let current_width = app.state.library.root.child("rect1").unwrap() + .input("width").unwrap().value.as_float().unwrap(); + assert!((current_width - 200.0).abs() < 0.001); + + // Undo should restore to width=100 + let sel = SelectionSnapshot::default(); + if let Some((restored, _)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + } + let restored_width = app.state.library.root.child("rect1").unwrap() + .input("width").unwrap().value.as_float().unwrap(); + assert!((restored_width - 100.0).abs() < 0.001, + "Expected width=100 after undo, got {}", restored_width); + } + + /// Test that non-drag changes still create individual undo entries. + #[test] + fn test_non_drag_changes_still_create_undo_entries() { + let mut app = NodeBoxApp::new_for_testing(); + + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Make two separate changes (not grouped) + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(150.0); + } + } + app.check_for_changes(); + + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(200.0); + } + } + app.check_for_changes(); + + // Should have 2 separate undo entries + assert_eq!(app.history.undo_count(), 2, + "Non-drag changes should create separate undo entries"); + } + + /// Test that a drag followed by a normal change creates 2 undo entries. + #[test] + fn test_drag_then_normal_change() { + let mut app = NodeBoxApp::new_for_testing(); + + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Drag operation + let pre_drag = Arc::clone(&app.state.library); + let sel = SelectionSnapshot::default(); + app.history.begin_undo_group(&pre_drag, &sel); + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(200.0); + } + } + app.check_for_changes(); + app.history.end_undo_group(&app.state.library); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Normal change after drag + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("height") { + port.value = nodebox_core::Value::Float(200.0); + } + } + app.check_for_changes(); + + // Should have 2 entries: one from drag group + one from normal change + assert_eq!(app.history.undo_count(), 2); + } + + /// Test that a single change can be undone (not just counted). + /// This is the core bug: check_for_changes saved the post-mutation state, + /// so undoing returned the same state. + #[test] + fn test_single_change_undo_actually_restores_previous_state() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + // Set up a rect with width=100 + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Change width to 200 + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(200.0); + } + } + app.check_for_changes(); + + // Verify the change took effect + let width = app.state.library.root.child("rect1").unwrap() + .input("width").unwrap().value.as_float().unwrap(); + assert!((width - 200.0).abs() < 0.001); + + // Undo should restore width=100 + { + let sel = SelectionSnapshot::default(); + if let Some((restored, _)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + } + } + let restored_width = app.state.library.root.child("rect1").unwrap() + .input("width").unwrap().value.as_float().unwrap(); + assert!((restored_width - 100.0).abs() < 0.001, + "Expected width=100 after undo, got {}", restored_width); + } + + /// Test that creating a node can be undone. + #[test] + fn test_undo_node_creation() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + let initial_count = app.state.library.root.children.len(); + + // Create a new node (simulates what the node dialog does) + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + app.check_for_changes(); + + assert_eq!(app.state.library.root.children.len(), initial_count + 1, + "Node should have been added"); + + // Undo should remove the node + { + let sel = SelectionSnapshot::default(); + if let Some((restored, _)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + } + } + assert_eq!(app.state.library.root.children.len(), initial_count, + "Undo should remove the created node"); + } + + /// Test that connecting nodes can be undone. + #[test] + fn test_undo_connection() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + // Set up two nodes + { + let lib = Arc::make_mut(&mut app.state.library); + lib.root.children.push( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ); + lib.root.children.push( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")), + ); + } + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + assert_eq!(app.state.library.root.connections.len(), 0); + + // Connect them + Arc::make_mut(&mut app.state.library).root.connect( + nodebox_core::node::Connection::new("ellipse1", "colorize1", "shape") + ); + app.check_for_changes(); + + assert_eq!(app.state.library.root.connections.len(), 1, + "Connection should exist"); + + // Undo should remove the connection + { + let sel = SelectionSnapshot::default(); + if let Some((restored, _)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + } + } + assert_eq!(app.state.library.root.connections.len(), 0, + "Undo should remove the connection"); + } + + /// Test that setting the rendered node can be undone. + #[test] + fn test_undo_set_rendered_node() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + // Set up two nodes with ellipse1 as rendered + { + let lib = Arc::make_mut(&mut app.state.library); + lib.root.children.push( + Node::new("ellipse1").with_prototype("corevector.ellipse"), + ); + lib.root.children.push( + Node::new("rect1").with_prototype("corevector.rect"), + ); + lib.root.rendered_child = Some("ellipse1".to_string()); + } + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Change rendered to rect1 + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.check_for_changes(); + + assert_eq!(app.state.library.root.rendered_child.as_deref(), Some("rect1")); + + // Undo should restore rendered to ellipse1 + { + let sel = SelectionSnapshot::default(); + if let Some((restored, _)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + } + } + assert_eq!(app.state.library.root.rendered_child.as_deref(), Some("ellipse1"), + "Undo should restore the rendered node to ellipse1"); + } + + /// Test the exact user-reported scenario: set rendered node, drag node, then undo. + /// Should take exactly 2 undos (not 3 with an empty one). + #[test] + fn test_set_rendered_then_drag_undo_no_empty_steps() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + // Set up two nodes with ellipse1 as rendered + { + let lib = Arc::make_mut(&mut app.state.library); + lib.root.children.push( + Node::new("ellipse1").with_prototype("corevector.ellipse") + .with_input(Port::float("width", 100.0)), + ); + lib.root.children.push( + Node::new("rect1").with_prototype("corevector.rect") + .with_input(Port::float("width", 50.0)), + ); + lib.root.rendered_child = Some("ellipse1".to_string()); + } + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Operation 1: Set rendered to rect1 + Arc::make_mut(&mut app.state.library).root.rendered_child = Some("rect1".to_string()); + app.check_for_changes(); + + // Operation 2: Drag (change width from 50 to 200) + let pre_drag = Arc::clone(&app.state.library); + let sel = SelectionSnapshot::default(); + app.history.begin_undo_group(&pre_drag, &sel); + for i in 1..=5 { + if let Some(node) = Arc::make_mut(&mut app.state.library).root.child_mut("rect1") { + if let Some(port) = node.input_mut("width") { + port.value = nodebox_core::Value::Float(50.0 + i as f64 * 30.0); + } + } + app.check_for_changes(); + } + app.history.end_undo_group(&app.state.library); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + + // Should have exactly 2 undo entries (set rendered + drag) + assert_eq!(app.history.undo_count(), 2, + "Should have exactly 2 undo entries, not more"); + + // Undo #1: Should undo the drag (width back to 50) + { + let sel = SelectionSnapshot::default(); + if let Some((restored, _)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + } + } + let width = app.state.library.root.child("rect1").unwrap() + .input("width").unwrap().value.as_float().unwrap(); + assert!((width - 50.0).abs() < 0.001, + "First undo should restore width to 50, got {}", width); + assert_eq!(app.state.library.root.rendered_child.as_deref(), Some("rect1"), + "First undo should keep rendered=rect1"); + + // Undo #2: Should undo the rendered node change (back to ellipse1) + { + let sel = SelectionSnapshot::default(); + if let Some((restored, _)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + } + } + assert_eq!(app.state.library.root.rendered_child.as_deref(), Some("ellipse1"), + "Second undo should restore rendered to ellipse1"); + } + + /// Test that undoing node creation clears the selection. + /// When a node is created and selected, undoing should clear the selection + /// because the node no longer exists. + #[test] + fn test_undo_node_creation_clears_selection() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + // Create a new node and select it + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::float("width", 100.0)), + ); + app.state.selected_node = Some("ellipse1".to_string()); + app.network_view.set_selected(["ellipse1".to_string()].into_iter().collect()); + app.check_for_changes(); + + assert_eq!(app.state.selected_node.as_deref(), Some("ellipse1")); + assert!(app.network_view.selected_nodes().contains("ellipse1")); + + // Undo: node is removed, selection should be cleared + let sel = app.current_selection(); + if let Some((restored, restored_sel)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + app.apply_selection(restored_sel); + } + + assert_eq!(app.state.selected_node, None, + "Selection should be cleared after undoing node creation"); + assert!(app.network_view.selected_nodes().is_empty(), + "Network view selection should be empty after undoing node creation"); + } + + /// Test that undoing restores the previous selection. + /// If node A was selected, then node B was created and selected, + /// undoing should restore node A as selected. + #[test] + fn test_undo_restores_previous_selection() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + // Start with node A + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::float("width", 100.0)), + ); + app.state.selected_node = Some("rect1".to_string()); + app.network_view.set_selected(["rect1".to_string()].into_iter().collect()); + app.previous_library_hash = NodeBoxApp::hash_library(&app.state.library); + app.previous_library = Arc::clone(&app.state.library); + app.previous_selection = app.current_selection(); + + // Create node B and select it + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::float("width", 100.0)), + ); + app.state.selected_node = Some("ellipse1".to_string()); + app.network_view.set_selected(["ellipse1".to_string()].into_iter().collect()); + app.check_for_changes(); + + assert_eq!(app.state.selected_node.as_deref(), Some("ellipse1")); + + // Undo: node B removed, selection should restore to rect1 + let sel = app.current_selection(); + if let Some((restored, restored_sel)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + app.apply_selection(restored_sel); + } + + assert_eq!(app.state.selected_node.as_deref(), Some("rect1"), + "Undo should restore previous selection to rect1"); + assert!(app.network_view.selected_nodes().contains("rect1"), + "Network view should show rect1 selected after undo"); + } + + /// Test that redo restores the selection from before the undo. + #[test] + fn test_redo_restores_selection() { + let mut app = NodeBoxApp::new_for_testing_empty(); + + // Create node and select it + Arc::make_mut(&mut app.state.library).root.children.push( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::float("width", 100.0)), + ); + app.state.selected_node = Some("ellipse1".to_string()); + app.network_view.set_selected(["ellipse1".to_string()].into_iter().collect()); + app.check_for_changes(); + + // Undo: removes node, clears selection + let sel = app.current_selection(); + if let Some((restored, restored_sel)) = app.history.undo(&app.state.library, &sel) { + app.state.library = restored; + app.apply_selection(restored_sel); + } + + assert_eq!(app.state.selected_node, None); + + // Redo: restores node and selection + let sel = app.current_selection(); + if let Some((restored, restored_sel)) = app.history.redo(&app.state.library, &sel) { + app.state.library = restored; + app.apply_selection(restored_sel); + } + + assert_eq!(app.state.selected_node.as_deref(), Some("ellipse1"), + "Redo should restore selection to ellipse1"); + assert!(app.network_view.selected_nodes().contains("ellipse1"), + "Network view should show ellipse1 selected after redo"); + } +} diff --git a/crates/nodebox-gui/src/canvas.rs b/crates/nodebox-desktop/src/canvas.rs similarity index 87% rename from crates/nodebox-gui/src/canvas.rs rename to crates/nodebox-desktop/src/canvas.rs index 7baf4dab..d1477902 100644 --- a/crates/nodebox-gui/src/canvas.rs +++ b/crates/nodebox-desktop/src/canvas.rs @@ -361,6 +361,48 @@ impl CanvasViewer { // Skip curve data points, they're handled with CurveTo i += 1; } + PointType::QuadTo => { + // For quadratic curves, we need to sample points + // Get the control point and end point + if i + 1 < contour.points.len() { + let ctrl = &contour.points[i]; + let end = &contour.points[i + 1]; + + // Get last point as start + let start = if let Some(&last) = egui_points.last() { + last + } else { + screen_pt + }; + + let c = (Pos2::new(ctrl.point.x as f32, ctrl.point.y as f32).to_vec2() + * self.zoom + + self.pan + + center) + .to_pos2(); + let e = (Pos2::new(end.point.x as f32, end.point.y as f32).to_vec2() + * self.zoom + + self.pan + + center) + .to_pos2(); + + // Sample the quadratic bezier + for t in 1..=10 { + let t = t as f32 / 10.0; + let pt = quadratic_bezier(start, c, e, t); + egui_points.push(pt); + } + + i += 2; + } else { + egui_points.push(screen_pt); + i += 1; + } + } + PointType::QuadData => { + // Skip quad data points, they're handled with QuadTo + i += 1; + } } } @@ -427,3 +469,15 @@ fn cubic_bezier(p0: Pos2, p1: Pos2, p2: Pos2, p3: Pos2, t: f32) -> Pos2 { mt3 * p0.y + 3.0 * mt2 * t * p1.y + 3.0 * mt * t2 * p2.y + t3 * p3.y, ) } + +/// Evaluate a quadratic bezier curve at parameter t. +fn quadratic_bezier(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 { + let t2 = t * t; + let mt = 1.0 - t; + let mt2 = mt * mt; + + Pos2::new( + mt2 * p0.x + 2.0 * mt * t * p1.x + t2 * p2.x, + mt2 * p0.y + 2.0 * mt * t * p1.y + t2 * p2.y, + ) +} diff --git a/crates/nodebox-gui/src/components.rs b/crates/nodebox-desktop/src/components.rs similarity index 60% rename from crates/nodebox-gui/src/components.rs rename to crates/nodebox-desktop/src/components.rs index 1cfebe77..6f081813 100644 --- a/crates/nodebox-gui/src/components.rs +++ b/crates/nodebox-desktop/src/components.rs @@ -45,7 +45,7 @@ pub fn draw_pane_header_with_title( egui::pos2(aligned_rect.left(), aligned_rect.top() + 0.5), egui::pos2(aligned_rect.right(), aligned_rect.top() + 0.5), ], - egui::Stroke::new(1.0, theme::SLATE_700), + egui::Stroke::new(1.0, theme::ZINC_600), ); // Title on left (UPPERCASE) @@ -72,13 +72,13 @@ pub fn draw_pane_header_with_title( egui::Stroke::new(1.0, theme::TEXT_DISABLED), ); - // Bottom border (1px dark line) - draw at bottom edge + // Bottom border (1px dark line) ui.painter().line_segment( [ egui::pos2(aligned_rect.left(), aligned_rect.bottom() - 0.5), egui::pos2(aligned_rect.right(), aligned_rect.bottom() - 0.5), ], - egui::Stroke::new(1.0, theme::SLATE_950), + egui::Stroke::new(1.0, theme::ZINC_900), ); // Return header rect and x position after separator (8px margin) @@ -248,6 +248,7 @@ pub fn header_segmented_control( x: f32, labels: [&str; 2], selected: usize, + disabled_index: Option, ) -> (Option, f32) { let font = egui::FontId::proportional(11.0); let padding_h = 6.0; // Horizontal padding inside each segment @@ -275,17 +276,20 @@ pub fn header_segmented_control( let content_height = content_bottom - content_top; // Only draw the selected segment (unselected is transparent/header bg) - let selected_x = if selected == 0 { x } else { x + width0 }; - let selected_width = if selected == 0 { width0 } else { width1 }; - let selected_rect = Rect::from_min_size( - egui::pos2(selected_x, content_top), - egui::vec2(selected_width, content_height), - ); - ui.painter().rect_filled( - selected_rect, - 0.0, - theme::SLATE_700, - ); + // Don't draw highlight if the selected segment is disabled + if disabled_index != Some(selected) { + let selected_x = if selected == 0 { x } else { x + width0 }; + let selected_width = if selected == 0 { width0 } else { width1 }; + let selected_rect = Rect::from_min_size( + egui::pos2(selected_x, content_top), + egui::vec2(selected_width, content_height), + ); + ui.painter().rect_filled( + selected_rect, + 0.0, + theme::ZINC_600, + ); + } // Create interaction rects and draw labels let rect0 = Rect::from_min_size( @@ -308,9 +312,21 @@ pub fn header_segmented_control( egui::Sense::click(), ); - // Draw text labels - let color0 = if selected == 0 { theme::TEXT_STRONG } else { theme::TEXT_SUBDUED }; - let color1 = if selected == 1 { theme::TEXT_STRONG } else { theme::TEXT_SUBDUED }; + // Draw text labels (disabled segments use TEXT_DISABLED) + let color0 = if disabled_index == Some(0) { + theme::TEXT_DISABLED + } else if selected == 0 { + theme::TEXT_STRONG + } else { + theme::TEXT_SUBDUED + }; + let color1 = if disabled_index == Some(1) { + theme::TEXT_DISABLED + } else if selected == 1 { + theme::TEXT_STRONG + } else { + theme::TEXT_SUBDUED + }; ui.painter().text( egui::pos2(x + width0 / 2.0, y_center), @@ -327,9 +343,9 @@ pub fn header_segmented_control( color1, ); - let clicked = if response0.clicked() { + let clicked = if response0.clicked() && disabled_index != Some(0) { Some(0) - } else if response1.clicked() { + } else if response1.clicked() && disabled_index != Some(1) { Some(1) } else { None @@ -337,3 +353,134 @@ pub fn header_segmented_control( (clicked, x + total_width) } + +/// Draw a zoom percentage control in a header (styled like animation_bar.rs DragValue). +/// +/// Returns (new_zoom, new_x) where new_zoom is the updated zoom value (0.1 to 10.0), +/// or None if not changed. +pub fn header_zoom_control( + ui: &mut egui::Ui, + header_rect: Rect, + x: f32, + zoom: f32, +) -> (Option, f32) { + let width = 48.0; + + // Convert zoom to percentage for display (e.g., 1.0 -> 100) + let mut zoom_percent = (zoom * 100.0).round() as i32; + + // Override visuals for styled DragValue (matching animation_bar.rs) + let old_visuals = ui.visuals().clone(); + let old_spacing = ui.spacing().clone(); + + // All states: no borders, sharp corners, appropriate fill + ui.visuals_mut().widgets.inactive.bg_fill = theme::ZINC_700; + ui.visuals_mut().widgets.inactive.weak_bg_fill = theme::ZINC_700; + ui.visuals_mut().widgets.inactive.bg_stroke = egui::Stroke::NONE; + ui.visuals_mut().widgets.inactive.fg_stroke = egui::Stroke::new(1.0, theme::TEXT_DEFAULT); + ui.visuals_mut().widgets.inactive.corner_radius = egui::CornerRadius::ZERO; + ui.visuals_mut().widgets.inactive.expansion = 0.0; + + ui.visuals_mut().widgets.hovered.bg_fill = theme::ZINC_600; + ui.visuals_mut().widgets.hovered.weak_bg_fill = theme::ZINC_600; + ui.visuals_mut().widgets.hovered.bg_stroke = egui::Stroke::NONE; + ui.visuals_mut().widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme::TEXT_STRONG); + ui.visuals_mut().widgets.hovered.corner_radius = egui::CornerRadius::ZERO; + ui.visuals_mut().widgets.hovered.expansion = 0.0; + + ui.visuals_mut().widgets.active.bg_fill = theme::ZINC_600; + ui.visuals_mut().widgets.active.weak_bg_fill = theme::ZINC_600; + ui.visuals_mut().widgets.active.bg_stroke = egui::Stroke::NONE; + ui.visuals_mut().widgets.active.fg_stroke = egui::Stroke::new(1.0, theme::TEXT_STRONG); + ui.visuals_mut().widgets.active.corner_radius = egui::CornerRadius::ZERO; + ui.visuals_mut().widgets.active.expansion = 0.0; + + ui.visuals_mut().widgets.noninteractive.bg_fill = theme::ZINC_700; + ui.visuals_mut().widgets.noninteractive.weak_bg_fill = theme::ZINC_700; + ui.visuals_mut().widgets.noninteractive.bg_stroke = egui::Stroke::NONE; + ui.visuals_mut().widgets.noninteractive.corner_radius = egui::CornerRadius::ZERO; + ui.visuals_mut().widgets.noninteractive.expansion = 0.0; + + // Use consistent padding + ui.spacing_mut().button_padding = egui::vec2(4.0, 2.0); + + // Allocate exact size rect within header, respecting 1px top/bottom borders + let content_top = header_rect.top() + 1.0; + let content_bottom = header_rect.bottom() - 1.0; + let content_height = content_bottom - content_top; + let rect = Rect::from_min_size( + egui::pos2(x, content_top), + egui::vec2(width, content_height), + ); + + let old_zoom_percent = zoom_percent; + let response = ui.put( + rect, + egui::DragValue::new(&mut zoom_percent) + .range(10..=1000) + .speed(1.0) + .suffix("%"), + ); + + // Restore visuals and spacing + *ui.visuals_mut() = old_visuals; + *ui.spacing_mut() = old_spacing; + + // Check if value changed + let new_zoom = if zoom_percent != old_zoom_percent || response.lost_focus() { + Some(zoom_percent as f32 / 100.0) + } else { + None + }; + + (new_zoom, x + width) +} + +/// Draw a small icon button in a header (for zoom +/- buttons). +/// +/// Returns true if clicked. +pub fn header_icon_button( + ui: &mut egui::Ui, + header_rect: Rect, + x: f32, + icon: &str, +) -> (bool, f32) { + let width = 24.0; + let button_rect = Rect::from_min_size( + egui::pos2(x, header_rect.top()), + egui::vec2(width, header_rect.height()), + ); + + let response = ui.interact( + button_rect, + ui.id().with(format!("icon_{}", icon)), + egui::Sense::click(), + ); + + // Draw hover highlight + if response.hovered() { + ui.painter().rect_filled( + button_rect, + 0.0, + theme::ZINC_600, + ); + } + + // Draw icon text centered + let font = egui::FontId::proportional(14.0); + let color = if response.hovered() { + theme::TEXT_STRONG + } else { + theme::TEXT_DEFAULT + }; + + ui.painter().text( + button_rect.center(), + egui::Align2::CENTER_CENTER, + icon, + font, + color, + ); + + (response.clicked(), x + width) +} diff --git a/crates/nodebox-desktop/src/desktop_platform.rs b/crates/nodebox-desktop/src/desktop_platform.rs new file mode 100644 index 00000000..455e038e --- /dev/null +++ b/crates/nodebox-desktop/src/desktop_platform.rs @@ -0,0 +1,858 @@ +//! Desktop (macOS, Windows, Linux) implementation of the Platform trait. + +use nodebox_core::platform::{ + DirectoryEntry, FileFilter, LogLevel, PlatformInfo, Platform, PlatformError, ProjectContext, + RelativePath, +}; +use std::path::{Path, PathBuf}; + +/// Desktop implementation of the Platform trait. +/// +/// Uses native filesystem, rfd for dialogs, arboard for clipboard, and ureq for HTTP. +#[derive(Debug, Default)] +pub struct DesktopPlatform; + +impl DesktopPlatform { + /// Create a new DesktopPlatform instance. + pub fn new() -> Self { + Self + } + + /// Get the library directory for the current platform. + fn library_dir(&self) -> Result { + if let Some(proj_dirs) = + directories::ProjectDirs::from("net", "nodebox", "NodeBox") + { + Ok(proj_dirs.data_dir().join("libraries")) + } else { + Err(PlatformError::Other( + "Could not determine library directory".to_string(), + )) + } + } + + /// Validate that a path is within the project directory and convert to RelativePath. + /// + /// This is a security function that ensures file access is sandboxed to the project. + fn validate_within_project( + ctx: &ProjectContext, + path: &Path, + ) -> Result { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + + // Canonicalize both paths to resolve symlinks and normalize + let canonical_root = root.canonicalize().map_err(|e| { + PlatformError::IoError(format!("Failed to canonicalize project root: {}", e)) + })?; + let canonical_path = path.canonicalize().map_err(|e| { + PlatformError::IoError(format!("Failed to canonicalize selected path: {}", e)) + })?; + + // Check if the selected path is within the project root + if canonical_path.starts_with(&canonical_root) { + let relative = canonical_path + .strip_prefix(&canonical_root) + .map_err(|_| PlatformError::SandboxViolation)?; + RelativePath::new(relative) + } else { + Err(PlatformError::SandboxViolation) + } + } + + /// Validate that a path (which may not exist yet) would be within the project directory. + /// + /// This is used for save dialogs where the file doesn't exist yet, so we can't canonicalize it. + fn validate_save_path_within_project( + ctx: &ProjectContext, + path: &Path, + ) -> Result { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + + // Canonicalize the project root + let canonical_root = root.canonicalize().map_err(|e| { + PlatformError::IoError(format!("Failed to canonicalize project root: {}", e)) + })?; + + // For the save path, canonicalize the parent directory (which should exist) + // and then append the filename + if let Some(parent) = path.parent() { + if let Some(file_name) = path.file_name() { + // Try to canonicalize the parent. If it doesn't exist, try its parent, etc. + let canonical_parent = if parent.exists() { + parent.canonicalize().map_err(|e| { + PlatformError::IoError(format!( + "Failed to canonicalize parent directory: {}", + e + )) + })? + } else { + // Parent doesn't exist - this is a new nested directory + // Check if any ancestor is within the project + let mut ancestor = parent.to_path_buf(); + loop { + if ancestor.exists() { + break ancestor.canonicalize().map_err(|e| { + PlatformError::IoError(format!( + "Failed to canonicalize ancestor: {}", + e + )) + })?; + } + if let Some(p) = ancestor.parent() { + ancestor = p.to_path_buf(); + } else { + return Err(PlatformError::SandboxViolation); + } + } + }; + + // Check if the canonical parent is within or equals the project root + if canonical_parent.starts_with(&canonical_root) { + // Build the relative path from project root to the save location + let relative_parent = if canonical_parent == canonical_root { + PathBuf::new() + } else { + canonical_parent + .strip_prefix(&canonical_root) + .map_err(|_| PlatformError::SandboxViolation)? + .to_path_buf() + }; + + // Append any non-existent directory components from the original path + let original_parent = parent.to_path_buf(); + let existing_ancestor = if parent.exists() { + parent.canonicalize().ok() + } else { + let mut a = parent.to_path_buf(); + while !a.exists() { + if let Some(p) = a.parent() { + a = p.to_path_buf(); + } else { + break; + } + } + a.canonicalize().ok() + }; + + let full_relative = if let Some(existing) = existing_ancestor { + if let Ok(suffix) = original_parent.strip_prefix( + existing + .strip_prefix(&canonical_root) + .map(|p| canonical_root.join(p)) + .unwrap_or(existing.clone()), + ) { + relative_parent.join(suffix).join(file_name) + } else { + relative_parent.join(file_name) + } + } else { + relative_parent.join(file_name) + }; + + RelativePath::new(full_relative) + } else { + Err(PlatformError::SandboxViolation) + } + } else { + Err(PlatformError::SandboxViolation) + } + } else { + // Path has no parent - just a filename, which is fine + RelativePath::new(path) + } + } +} + +impl Platform for DesktopPlatform { + fn platform_info(&self) -> PlatformInfo { + PlatformInfo::current() + } + + fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let full_path = root.join(path.as_path()); + std::fs::read(&full_path).map_err(PlatformError::from) + } + + fn write_file( + &self, + ctx: &ProjectContext, + path: &RelativePath, + data: &[u8], + ) -> Result<(), PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let full_path = root.join(path.as_path()); + + // Create parent directories if needed + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(&full_path, data).map_err(PlatformError::from) + } + + fn list_directory( + &self, + ctx: &ProjectContext, + path: &RelativePath, + ) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let full_path = root.join(path.as_path()); + + let entries = std::fs::read_dir(&full_path)? + .filter_map(|entry| { + entry.ok().map(|e| { + DirectoryEntry::new( + e.file_name().to_string_lossy().to_string(), + e.file_type().map(|ft| ft.is_dir()).unwrap_or(false), + ) + }) + }) + .collect(); + + Ok(entries) + } + + fn read_text_file(&self, ctx: &ProjectContext, path: &str) -> Result { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let relative = RelativePath::new(path)?; + let full_path = root.join(relative.as_path()); + let bytes = std::fs::read(&full_path)?; + String::from_utf8(bytes).map_err(|_| PlatformError::IoError("Invalid UTF-8".to_string())) + } + + fn read_binary_file(&self, ctx: &ProjectContext, path: &str) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let relative = RelativePath::new(path)?; + let full_path = root.join(relative.as_path()); + std::fs::read(&full_path).map_err(PlatformError::from) + } + + fn load_app_resource(&self, name: &str) -> Result, PlatformError> { + // Locate resources relative to executable or in standard location + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + + let resource_dirs = [ + exe_dir.as_ref().map(|d| d.join("resources")), + exe_dir.as_ref().map(|d| d.join("../Resources")), // macOS app bundle + Some(PathBuf::from("resources")), // Development fallback + ]; + + for dir in resource_dirs.iter().flatten() { + let path = dir.join(name); + if path.exists() { + return std::fs::read(&path).map_err(PlatformError::from); + } + } + + Err(PlatformError::NotFound) + } + + fn read_project(&self, ctx: &ProjectContext) -> Result, PlatformError> { + let path = ctx.project_path().ok_or(PlatformError::Unsupported)?; + std::fs::read(&path).map_err(PlatformError::from) + } + + fn write_project(&self, ctx: &ProjectContext, data: &[u8]) -> Result<(), PlatformError> { + let path = ctx.project_path().ok_or(PlatformError::Unsupported)?; + + // Create parent directories if needed + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(&path, data).map_err(PlatformError::from) + } + + fn load_library(&self, name: &str) -> Result, PlatformError> { + let library_dir = self.library_dir()?; + let library_path = library_dir.join(format!("{}.ndbx", name)); + + if !library_path.exists() { + return Err(PlatformError::LibraryNotFound(name.to_string())); + } + + std::fs::read(&library_path).map_err(PlatformError::from) + } + + fn http_get(&self, url: &str) -> Result, PlatformError> { + let response = ureq::get(url) + .call() + .map_err(|e| PlatformError::NetworkError(e.to_string()))?; + + let mut bytes = Vec::new(); + response + .into_reader() + .read_to_end(&mut bytes) + .map_err(|e| PlatformError::NetworkError(e.to_string()))?; + + Ok(bytes) + } + + fn show_open_project_dialog( + &self, + filters: &[FileFilter], + ) -> Result, PlatformError> { + let mut dialog = rfd::FileDialog::new(); + + for filter in filters { + let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter(&filter.name, &extensions); + } + + Ok(dialog.pick_file()) + } + + fn show_save_project_dialog( + &self, + filters: &[FileFilter], + default_name: Option<&str>, + ) -> Result, PlatformError> { + let mut dialog = rfd::FileDialog::new(); + + for filter in filters { + let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter(&filter.name, &extensions); + } + + if let Some(name) = default_name { + dialog = dialog.set_file_name(name); + } + + Ok(dialog.save_file()) + } + + fn show_open_file_dialog( + &self, + ctx: &ProjectContext, + filters: &[FileFilter], + ) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let mut dialog = rfd::FileDialog::new(); + + // Start in the project directory + dialog = dialog.set_directory(root); + + for filter in filters { + let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter(&filter.name, &extensions); + } + + match dialog.pick_file() { + Some(path) => { + // Validate that the selected file is within the project directory + let relative = Self::validate_within_project(ctx, &path)?; + Ok(Some(relative)) + } + None => Ok(None), + } + } + + fn show_save_file_dialog( + &self, + ctx: &ProjectContext, + filters: &[FileFilter], + default_name: Option<&str>, + ) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let mut dialog = rfd::FileDialog::new(); + + // Start in the project directory + dialog = dialog.set_directory(root); + + for filter in filters { + let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter(&filter.name, &extensions); + } + + if let Some(name) = default_name { + dialog = dialog.set_file_name(name); + } + + match dialog.save_file() { + Some(path) => { + // Validate that the selected location is within the project directory + let relative = Self::validate_save_path_within_project(ctx, &path)?; + Ok(Some(relative)) + } + None => Ok(None), + } + } + + fn show_select_folder_dialog( + &self, + ctx: &ProjectContext, + ) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; + let mut dialog = rfd::FileDialog::new(); + + // Start in the project directory + dialog = dialog.set_directory(root); + + match dialog.pick_folder() { + Some(path) => { + // Validate that the selected folder is within the project directory + let relative = Self::validate_within_project(ctx, &path)?; + Ok(Some(relative)) + } + None => Ok(None), + } + } + + fn show_confirm_dialog(&self, title: &str, message: &str) -> Result { + let result = rfd::MessageDialog::new() + .set_title(title) + .set_description(message) + .set_buttons(rfd::MessageButtons::OkCancel) + .show(); + + Ok(result == rfd::MessageDialogResult::Ok) + } + + fn show_message_dialog( + &self, + title: &str, + message: &str, + buttons: &[&str], + ) -> Result, PlatformError> { + // rfd doesn't support custom button labels directly + // Map to available button types based on count + let button_type = match buttons.len() { + 2 => rfd::MessageButtons::OkCancel, + 3 => rfd::MessageButtons::YesNoCancel, + _ => rfd::MessageButtons::Ok, + }; + + let result = rfd::MessageDialog::new() + .set_title(title) + .set_description(message) + .set_buttons(button_type) + .show(); + + // Map result back to button index + let index = match result { + rfd::MessageDialogResult::Ok | rfd::MessageDialogResult::Yes => Some(0), + rfd::MessageDialogResult::No => Some(1), + rfd::MessageDialogResult::Cancel => { + if buttons.len() == 2 { + Some(1) + } else { + Some(2) + } + } + rfd::MessageDialogResult::Custom(_) => None, + }; + + Ok(index) + } + + fn clipboard_read_text(&self) -> Result, PlatformError> { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| PlatformError::Other(e.to_string()))?; + + match clipboard.get_text() { + Ok(text) => Ok(Some(text)), + Err(arboard::Error::ContentNotAvailable) => Ok(None), + Err(e) => Err(PlatformError::Other(e.to_string())), + } + } + + fn clipboard_write_text(&self, text: &str) -> Result<(), PlatformError> { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| PlatformError::Other(e.to_string()))?; + + clipboard + .set_text(text) + .map_err(|e| PlatformError::Other(e.to_string())) + } + + fn log(&self, level: LogLevel, message: &str) { + match level { + LogLevel::Error => log::error!("{}", message), + LogLevel::Warn => log::warn!("{}", message), + LogLevel::Info => log::info!("{}", message), + LogLevel::Debug => log::debug!("{}", message), + } + } + + fn performance_mark(&self, name: &str) { + log::trace!("[PERF] {}", name); + } + + fn performance_mark_with_details(&self, name: &str, details: &str) { + log::trace!("[PERF] {} - {}", name, details); + } + + fn get_config_dir(&self) -> Result { + if let Some(proj_dirs) = + directories::ProjectDirs::from("net", "nodebox", "NodeBox") + { + Ok(proj_dirs.config_dir().to_path_buf()) + } else { + Err(PlatformError::Other( + "Could not determine config directory".to_string(), + )) + } + } + + fn list_fonts(&self) -> Vec { + let source = font_kit::source::SystemSource::new(); + let mut families = source.all_families().unwrap_or_default(); + families.sort(); + families + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_context() -> (TempDir, ProjectContext) { + let temp_dir = TempDir::new().unwrap(); + let ctx = ProjectContext::new(temp_dir.path(), "test.ndbx"); + (temp_dir, ctx) + } + + #[test] + fn test_platform_info() { + let port = DesktopPlatform::new(); + let info = port.platform_info(); + + assert!(info.has_filesystem); + assert!(info.has_native_dialogs); + assert!(!info.is_web); + } + + #[test] + fn test_read_write_file() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + let path = RelativePath::new("test.txt").unwrap(); + let data = b"Hello, World!"; + + // Write file + port.write_file(&ctx, &path, data).unwrap(); + + // Read file back + let read_data = port.read_file(&ctx, &path).unwrap(); + assert_eq!(read_data, data); + } + + #[test] + fn test_write_creates_directories() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + let path = RelativePath::new("subdir/nested/file.txt").unwrap(); + let data = b"nested content"; + + port.write_file(&ctx, &path, data).unwrap(); + + let read_data = port.read_file(&ctx, &path).unwrap(); + assert_eq!(read_data, data); + } + + #[test] + fn test_read_nonexistent_file() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + let path = RelativePath::new("nonexistent.txt").unwrap(); + let result = port.read_file(&ctx, &path); + + assert!(matches!(result, Err(PlatformError::NotFound))); + } + + #[test] + fn test_list_directory() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + // Create some files and directories + port.write_file(&ctx, &RelativePath::new("file1.txt").unwrap(), b"1") + .unwrap(); + port.write_file(&ctx, &RelativePath::new("file2.txt").unwrap(), b"2") + .unwrap(); + port.write_file(&ctx, &RelativePath::new("subdir/nested.txt").unwrap(), b"3") + .unwrap(); + + // List root directory + let root = RelativePath::new("").unwrap(); + let entries = port.list_directory(&ctx, &root).unwrap(); + + let names: Vec<_> = entries.iter().map(|e| &e.name).collect(); + assert!(names.contains(&&"file1.txt".to_string())); + assert!(names.contains(&&"file2.txt".to_string())); + assert!(names.contains(&&"subdir".to_string())); + + // Check that subdir is marked as directory + let subdir = entries.iter().find(|e| e.name == "subdir").unwrap(); + assert!(subdir.is_directory); + } + + #[test] + fn test_read_write_project() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + let data = b"project content"; + + port.write_project(&ctx, data).unwrap(); + + let read_data = port.read_project(&ctx).unwrap(); + assert_eq!(read_data, data); + } + + #[test] + fn test_load_library_not_found() { + let port = DesktopPlatform::new(); + let result = port.load_library("nonexistent_library_xyz"); + + assert!(matches!(result, Err(PlatformError::LibraryNotFound(_)))); + } + + #[test] + fn test_get_config_dir() { + let port = DesktopPlatform::new(); + let result = port.get_config_dir(); + + assert!(result.is_ok()); + let path = result.unwrap(); + // Should end with nodebox or NodeBox + let path_str = path.to_string_lossy().to_lowercase(); + assert!(path_str.contains("nodebox")); + } + + #[test] + fn test_log_levels() { + let port = DesktopPlatform::new(); + + // Just verify these don't panic + port.log(LogLevel::Error, "test error"); + port.log(LogLevel::Warn, "test warning"); + port.log(LogLevel::Info, "test info"); + port.log(LogLevel::Debug, "test debug"); + } + + #[test] + fn test_performance_marks() { + let port = DesktopPlatform::new(); + + // Just verify these don't panic + port.performance_mark("test-mark"); + port.performance_mark_with_details("test-mark", r#"{"key": "value"}"#); + } + + #[test] + fn test_validate_within_project_valid_path() { + let (_temp_dir, ctx) = create_test_context(); + + // Create a file inside the project + let file_path = ctx.root.as_ref().unwrap().join("test_file.txt"); + std::fs::write(&file_path, "test content").unwrap(); + + // Validation should succeed + let result = DesktopPlatform::validate_within_project(&ctx, &file_path); + assert!(result.is_ok()); + let relative = result.unwrap(); + assert_eq!(relative.as_path(), Path::new("test_file.txt")); + } + + #[test] + fn test_validate_within_project_nested_path() { + let (_temp_dir, ctx) = create_test_context(); + + // Create a nested file inside the project + let nested_dir = ctx.root.as_ref().unwrap().join("subdir"); + std::fs::create_dir_all(&nested_dir).unwrap(); + let file_path = nested_dir.join("nested_file.txt"); + std::fs::write(&file_path, "nested content").unwrap(); + + // Validation should succeed + let result = DesktopPlatform::validate_within_project(&ctx, &file_path); + assert!(result.is_ok()); + let relative = result.unwrap(); + assert_eq!(relative.as_path(), Path::new("subdir/nested_file.txt")); + } + + #[test] + fn test_validate_within_project_outside_path() { + let temp_dir = TempDir::new().unwrap(); + let project_dir = temp_dir.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let ctx = ProjectContext::new(&project_dir, "test.ndbx"); + + // Create a file outside the project directory (sibling) + let outside_file = temp_dir.path().join("outside.txt"); + std::fs::write(&outside_file, "outside content").unwrap(); + + // Validation should fail with SandboxViolation + let result = DesktopPlatform::validate_within_project(&ctx, &outside_file); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_validate_within_project_parent_traversal() { + let temp_dir = TempDir::new().unwrap(); + let project_dir = temp_dir.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let ctx = ProjectContext::new(&project_dir, "test.ndbx"); + + // Create a file at the parent level + let parent_file = temp_dir.path().join("parent.txt"); + std::fs::write(&parent_file, "parent content").unwrap(); + + // Validation should fail + let result = DesktopPlatform::validate_within_project(&ctx, &parent_file); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_validate_save_path_within_project_existing_file() { + let (_temp_dir, ctx) = create_test_context(); + + // Save path to an existing location (the file doesn't exist but parent does) + let save_path = ctx.root.as_ref().unwrap().join("new_file.txt"); + + let result = DesktopPlatform::validate_save_path_within_project(&ctx, &save_path); + assert!(result.is_ok()); + let relative = result.unwrap(); + assert_eq!(relative.as_path(), Path::new("new_file.txt")); + } + + #[test] + fn test_validate_save_path_within_project_nested() { + let (_temp_dir, ctx) = create_test_context(); + + // Create a subdirectory + let subdir = ctx.root.as_ref().unwrap().join("assets"); + std::fs::create_dir_all(&subdir).unwrap(); + + // Save path to the existing subdirectory + let save_path = subdir.join("new_image.png"); + + let result = DesktopPlatform::validate_save_path_within_project(&ctx, &save_path); + assert!(result.is_ok()); + let relative = result.unwrap(); + assert_eq!(relative.as_path(), Path::new("assets/new_image.png")); + } + + #[test] + fn test_validate_save_path_outside_project() { + let temp_dir = TempDir::new().unwrap(); + let project_dir = temp_dir.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let ctx = ProjectContext::new(&project_dir, "test.ndbx"); + + // Try to save outside the project + let outside_path = temp_dir.path().join("outside.txt"); + + let result = DesktopPlatform::validate_save_path_within_project(&ctx, &outside_path); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_read_text_file() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + // Create a text file + let file_path = ctx.root.as_ref().unwrap().join("test.txt"); + std::fs::write(&file_path, "Hello, World!").unwrap(); + + // Read it through the port + let content = port.read_text_file(&ctx, "test.txt").unwrap(); + assert_eq!(content, "Hello, World!"); + } + + #[test] + fn test_read_text_file_nested() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + // Create a nested text file + let subdir = ctx.root.as_ref().unwrap().join("assets"); + std::fs::create_dir_all(&subdir).unwrap(); + std::fs::write(subdir.join("data.txt"), "nested content").unwrap(); + + // Read it through the port + let content = port.read_text_file(&ctx, "assets/data.txt").unwrap(); + assert_eq!(content, "nested content"); + } + + #[test] + fn test_read_text_file_invalid_utf8() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + // Create a binary file (invalid UTF-8) + let file_path = ctx.root.as_ref().unwrap().join("binary.dat"); + std::fs::write(&file_path, &[0xFF, 0xFE, 0x00, 0x01]).unwrap(); + + // Should fail with IoError for invalid UTF-8 + let result = port.read_text_file(&ctx, "binary.dat"); + assert!(matches!(result, Err(PlatformError::IoError(_)))); + } + + #[test] + fn test_read_text_file_rejects_sandbox_violation() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + // Try to read with ".." in path + let result = port.read_text_file(&ctx, "../escape.txt"); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); + + // Try with absolute path + let result = port.read_text_file(&ctx, "/etc/passwd"); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_read_text_file_unsaved_project() { + let port = DesktopPlatform::new(); + let ctx = ProjectContext::new_unsaved(); + + // Should fail because project is unsaved + let result = port.read_text_file(&ctx, "test.txt"); + assert!(matches!(result, Err(PlatformError::Unsupported))); + } + + #[test] + fn test_read_binary_file() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + // Create a binary file + let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes + let file_path = ctx.root.as_ref().unwrap().join("image.png"); + std::fs::write(&file_path, &data).unwrap(); + + // Read it through the port + let content = port.read_binary_file(&ctx, "image.png").unwrap(); + assert_eq!(content, data); + } + + #[test] + fn test_read_binary_file_rejects_sandbox_violation() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPlatform::new(); + + // Try to read with ".." in path + let result = port.read_binary_file(&ctx, "../escape.bin"); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); + } + + #[test] + fn test_load_app_resource_not_found() { + let port = DesktopPlatform::new(); + + // Resources that don't exist should return NotFound + let result = port.load_app_resource("nonexistent/icon.png"); + assert!(matches!(result, Err(PlatformError::NotFound))); + } +} diff --git a/crates/nodebox-desktop/src/eval.rs b/crates/nodebox-desktop/src/eval.rs new file mode 100644 index 00000000..bc59fd57 --- /dev/null +++ b/crates/nodebox-desktop/src/eval.rs @@ -0,0 +1,3537 @@ +//! Network evaluation - executes node graphs to produce geometry. + +use std::collections::HashMap; +use std::sync::Arc; +use nodebox_core::geometry::{Path, Point, Color, Contour, PathPoint, PointType}; +use nodebox_core::geometry::font; +use nodebox_core::node::{Node, NodeLibrary, EvalError}; +use nodebox_core::node::PortRange; +use nodebox_core::Value; +use nodebox_core::platform::{Platform, ProjectContext}; +use nodebox_core::ops; +use ops::data::DataValue; +use crate::render_worker::CancellationToken; + +/// Error information for a specific node. +#[derive(Debug, Clone)] +pub struct NodeError { + /// Name of the node that had an error. + pub node_name: String, + /// Error message. + pub message: String, +} + +impl NodeError { + /// Create a new NodeError. + pub fn new(node_name: impl Into, message: impl Into) -> Self { + Self { + node_name: node_name.into(), + message: message.into(), + } + } +} + +/// Result type for evaluation operations. +pub type EvalResult = Result; + +/// Outcome of a cancellable evaluation. +pub enum EvalOutcome { + /// Evaluation completed successfully (may include errors). + Completed { + geometry: Vec, + output: NodeOutput, + errors: Vec, + }, + /// Evaluation was cancelled before completion. + Cancelled, +} + +/// The result of evaluating a node. +#[derive(Clone, Debug)] +pub enum NodeOutput { + /// No output (node not found or error). + None, + /// A single path. + Path(Path), + /// A list of paths. + Paths(Vec), + /// A single point. + Point(Point), + /// A list of points. + Points(Vec), + /// A float value. + Float(f64), + /// A list of float values. + Floats(Vec), + /// An integer value. + Int(i64), + /// A list of integer values. + Ints(Vec), + /// A string value. + String(String), + /// A list of string values. + Strings(Vec), + /// A color value. + Color(Color), + /// A list of color values. + Colors(Vec), + /// A boolean value. + Boolean(bool), + /// A list of boolean values. + Booleans(Vec), + /// A single data row (map of key-value pairs). + DataRow(HashMap), + /// A list of data rows. + DataRows(Vec>), +} + +impl NodeOutput { + /// Convert to a list of paths (for rendering). + pub fn to_paths(&self) -> Vec { + match self { + NodeOutput::Path(p) => vec![p.clone()], + NodeOutput::Paths(ps) => ps.clone(), + NodeOutput::Point(pt) => { + // Convert a single point to a path with one point + let mut path = Path::new(); + path.fill = None; // Points don't have fill + let contour = Contour::from_points( + vec![PathPoint::new(pt.x, pt.y, PointType::LineTo)], + false, + ); + path.contours.push(contour); + vec![path] + } + NodeOutput::Points(pts) => { + // Convert points to a path where each point is in a single contour + // This allows the viewer's draw_points to render them + let mut path = Path::new(); + path.fill = None; // Points don't have fill + for pt in pts { + let contour = Contour::from_points( + vec![PathPoint::new(pt.x, pt.y, PointType::LineTo)], + false, + ); + path.contours.push(contour); + } + vec![path] + } + _ => Vec::new(), + } + } + + /// Get as a single path if available. + #[allow(dead_code)] + pub fn as_path(&self) -> Option<&Path> { + match self { + NodeOutput::Path(p) => Some(p), + _ => None, + } + } + + /// Get as paths (single or list). + #[allow(dead_code)] + pub fn as_paths(&self) -> Option> { + match self { + NodeOutput::Path(p) => Some(vec![p.clone()]), + NodeOutput::Paths(ps) => Some(ps.clone()), + _ => None, + } + } + + /// Returns true if this output contains geometry or point data (visual types). + pub fn is_geometry(&self) -> bool { + matches!(self, NodeOutput::Path(_) | NodeOutput::Paths(_) | NodeOutput::Point(_) | NodeOutput::Points(_)) + } + + /// Returns true if this output contains data rows. + pub fn is_data_rows(&self) -> bool { + matches!(self, NodeOutput::DataRow(_) | NodeOutput::DataRows(_)) + } + + /// Extract data rows from the output, if it contains any. + pub fn as_data_rows(&self) -> Option<&Vec>> { + match self { + NodeOutput::DataRows(rows) => Some(rows), + _ => None, + } + } + + /// Extract a single data row from the output, if it is one. + pub fn as_data_row(&self) -> Option<&HashMap> { + match self { + NodeOutput::DataRow(row) => Some(row), + _ => None, + } + } + + /// Convert to a flat list of display strings for the data viewer. + pub fn to_display_strings(&self) -> Vec { + match self { + NodeOutput::None => vec![], + NodeOutput::Float(f) => vec![format!("{}", f)], + NodeOutput::Floats(fs) => fs.iter().map(|f| format!("{}", f)).collect(), + NodeOutput::Int(i) => vec![format!("{}", i)], + NodeOutput::Ints(is) => is.iter().map(|i| format!("{}", i)).collect(), + NodeOutput::String(s) => vec![s.clone()], + NodeOutput::Strings(ss) => ss.clone(), + NodeOutput::Boolean(b) => vec![format!("{}", b)], + NodeOutput::Booleans(bs) => bs.iter().map(|b| format!("{}", b)).collect(), + NodeOutput::Color(c) => vec![c.to_hex()], + NodeOutput::Colors(cs) => cs.iter().map(|c| c.to_hex()).collect(), + NodeOutput::Point(p) => vec![format!("{:.2}, {:.2}", p.x, p.y)], + NodeOutput::Points(pts) => pts.iter().map(|p| format!("{:.2}, {:.2}", p.x, p.y)).collect(), + NodeOutput::Path(_) => vec!["[Path]".to_string()], + NodeOutput::Paths(ps) => (0..ps.len()).map(|i| format!("[Path {}]", i)).collect(), + NodeOutput::DataRow(row) => { + let mut pairs: Vec = row.iter() + .map(|(k, v)| format!("{}: {}", k, v.as_string())) + .collect(); + pairs.sort(); // Stable display order + vec![format!("{{{}}}", pairs.join(", "))] + } + NodeOutput::DataRows(rows) => { + rows.iter().map(|row| { + let mut pairs: Vec = row.iter() + .map(|(k, v)| format!("{}: {}", k, v.as_string())) + .collect(); + pairs.sort(); + format!("{{{}}}", pairs.join(", ")) + }).collect() + } + } + } + + /// Returns a human-readable type label for the data viewer. + pub fn type_label(&self) -> &'static str { + match self { + NodeOutput::None => "none", + NodeOutput::Float(_) | NodeOutput::Floats(_) => "float", + NodeOutput::Int(_) | NodeOutput::Ints(_) => "int", + NodeOutput::String(_) | NodeOutput::Strings(_) => "string", + NodeOutput::Boolean(_) | NodeOutput::Booleans(_) => "boolean", + NodeOutput::Color(_) | NodeOutput::Colors(_) => "color", + NodeOutput::Point(_) | NodeOutput::Points(_) => "point", + NodeOutput::Path(_) | NodeOutput::Paths(_) => "path", + NodeOutput::DataRow(_) | NodeOutput::DataRows(_) => "data", + } + } + + /// Returns the count of items in this output. + pub fn item_count(&self) -> usize { + match self { + NodeOutput::None => 0, + NodeOutput::Paths(ps) => ps.len(), + NodeOutput::Points(pts) => pts.len(), + NodeOutput::Floats(fs) => fs.len(), + NodeOutput::Ints(is) => is.len(), + NodeOutput::Strings(ss) => ss.len(), + NodeOutput::Booleans(bs) => bs.len(), + NodeOutput::Colors(cs) => cs.len(), + NodeOutput::DataRows(rs) => rs.len(), + _ => 1, + } + } + + /// Returns true if this output is a color type. + pub fn is_color(&self) -> bool { + matches!(self, NodeOutput::Color(_) | NodeOutput::Colors(_)) + } + + /// Returns the color at the given index, if this is a color output. + pub fn color_at(&self, index: usize) -> Option { + match self { + NodeOutput::Color(c) => Some(*c), + NodeOutput::Colors(cs) => cs.get(index).copied(), + _ => None, + } + } + + /// Convert any output to a list of individual values for list matching. + fn to_value_list(&self) -> Vec { + match self { + NodeOutput::None => vec![], + NodeOutput::Path(p) => vec![NodeOutput::Path(p.clone())], + NodeOutput::Paths(ps) => ps.iter().map(|p| NodeOutput::Path(p.clone())).collect(), + NodeOutput::Point(p) => vec![NodeOutput::Point(*p)], + NodeOutput::Points(pts) => pts.iter().map(|p| NodeOutput::Point(*p)).collect(), + NodeOutput::Floats(fs) => fs.iter().map(|f| NodeOutput::Float(*f)).collect(), + NodeOutput::Ints(is) => is.iter().map(|i| NodeOutput::Int(*i)).collect(), + NodeOutput::Strings(ss) => ss.iter().map(|s| NodeOutput::String(s.clone())).collect(), + NodeOutput::Booleans(bs) => bs.iter().map(|b| NodeOutput::Boolean(*b)).collect(), + NodeOutput::Colors(cs) => cs.iter().map(|c| NodeOutput::Color(*c)).collect(), + NodeOutput::DataRows(rs) => rs.iter().map(|r| NodeOutput::DataRow(r.clone())).collect(), + v => vec![v.clone()], // Single values remain single + } + } + + /// Get the list length for this output (for list matching iteration count). + fn list_len(&self) -> usize { + match self { + NodeOutput::Paths(ps) => ps.len(), + NodeOutput::Points(pts) => pts.len(), + NodeOutput::Floats(fs) => fs.len(), + NodeOutput::Ints(is) => is.len(), + NodeOutput::Strings(ss) => ss.len(), + NodeOutput::Booleans(bs) => bs.len(), + NodeOutput::Colors(cs) => cs.len(), + NodeOutput::DataRows(rs) => rs.len(), + NodeOutput::None => 0, + _ => 1, + } + } + + /// Returns true if this output is a Point or Points type. + pub fn is_point_output(&self) -> bool { + matches!(self, NodeOutput::Point(_) | NodeOutput::Points(_)) + } +} + +/// Evaluate a node network and return the output of the rendered node along with any errors. +/// +/// Returns a tuple of (paths, errors). If there are errors, paths will be empty. +/// +/// The `port` and `project_context` are used for sandboxed file access (e.g., import_svg). +pub fn evaluate_network( + library: &NodeLibrary, + port: &Arc, + project_context: &ProjectContext, +) -> (Vec, NodeOutput, Vec) { + let network = &library.root; + + // Find the rendered child + let rendered_name = match &network.rendered_child { + Some(name) => name.clone(), + None => { + // No rendered child, return empty + return (Vec::new(), NodeOutput::None, Vec::new()); + } + }; + + // Create a cache for node outputs + let mut cache: HashMap = HashMap::new(); + + // Evaluate the rendered node (this will recursively evaluate dependencies) + let result = evaluate_node(network, &rendered_name, &mut cache, port, project_context); + + match result { + Ok(output) => { + let geometry = output.to_paths(); + (geometry, output, Vec::new()) + } + Err(e) => { + // Extract node name based on error type + let (node_name, message) = match &e { + EvalError::PortNotFound { node, port } => { + (node.clone(), format!("Missing required input '{}'", port)) + } + EvalError::NodeNotFound(name) => { + (name.clone(), "Node not found".to_string()) + } + EvalError::ProcessingError(msg) => { + // ProcessingError format: "nodename: message" + if let Some(pos) = msg.find(':') { + let name = msg[..pos].trim().to_string(); + let err_msg = msg[pos + 1..].trim().to_string(); + (name, err_msg) + } else { + (rendered_name.clone(), msg.clone()) + } + } + _ => (rendered_name.clone(), e.to_string()), + }; + (Vec::new(), NodeOutput::None, vec![NodeError::new(node_name, message)]) + } + } +} + +/// Evaluate a node network with cancellation support. +/// +/// This function supports cooperative cancellation via the provided token. +/// Cancellation is checked at two boundaries: +/// 1. Before evaluating each node +/// 2. During list-matching iterations +/// +/// The cache parameter is both input (for reusing previous results) and output +/// (for preserving partial results on cancellation). +/// +/// The `port` and `project_context` are used for sandboxed file access (e.g., import_svg). +pub fn evaluate_network_cancellable( + library: &NodeLibrary, + cancel_token: &CancellationToken, + cache: &mut HashMap, + port: &Arc, + project_context: &ProjectContext, +) -> EvalOutcome { + let network = &library.root; + + // Find the rendered child + let rendered_name = match &network.rendered_child { + Some(name) => name.clone(), + None => { + // No rendered child, return empty + return EvalOutcome::Completed { + geometry: Vec::new(), + output: NodeOutput::None, + errors: Vec::new(), + }; + } + }; + + // Check cancellation before starting + if cancel_token.is_cancelled() { + return EvalOutcome::Cancelled; + } + + // Convert cache to EvalResult format for internal use + let mut eval_cache: HashMap = cache + .drain() + .map(|(k, v)| (k, Ok(v))) + .collect(); + + // Evaluate the rendered node (this will recursively evaluate dependencies) + let result = evaluate_node_cancellable( + network, + &rendered_name, + &mut eval_cache, + cancel_token, + port, + project_context, + ); + + // Convert cache back to NodeOutput format (preserve successful results) + for (k, v) in eval_cache { + if let Ok(output) = v { + cache.insert(k, output); + } + } + + // Check if cancelled + if cancel_token.is_cancelled() { + return EvalOutcome::Cancelled; + } + + match result { + Ok(output) => { + EvalOutcome::Completed { + geometry: output.to_paths(), + output, + errors: Vec::new(), + } + } + Err(EvalError::Cancelled) => EvalOutcome::Cancelled, + Err(e) => { + // Extract node name based on error type + let (node_name, message) = match &e { + EvalError::PortNotFound { node, port } => { + (node.clone(), format!("Missing required input '{}'", port)) + } + EvalError::NodeNotFound(name) => { + (name.clone(), "Node not found".to_string()) + } + EvalError::ProcessingError(msg) => { + // ProcessingError format: "nodename: message" + if let Some(pos) = msg.find(':') { + let name = msg[..pos].trim().to_string(); + let err_msg = msg[pos + 1..].trim().to_string(); + (name, err_msg) + } else { + (rendered_name.clone(), msg.clone()) + } + } + _ => (rendered_name.clone(), e.to_string()), + }; + EvalOutcome::Completed { + geometry: Vec::new(), + output: NodeOutput::None, + errors: vec![NodeError::new(node_name, message)], + } + } + } +} + +/// Determine how many times to execute the node for list matching. +/// Returns None if any VALUE-range input is empty. +fn compute_iteration_count( + inputs: &HashMap, + node: &Node, +) -> Option { + let mut max_size = 1usize; + + // Check inputs that have corresponding port definitions with range info + for port in &node.inputs { + if port.range == PortRange::List { + continue; // LIST-range ports don't contribute to iteration count + } + if let Some(output) = inputs.get(&port.name) { + let size = output.list_len(); + if size == 0 { + return None; // Empty list → no output + } + max_size = max_size.max(size); + } + } + + // Also check inputs that don't have port definitions (from connections) + // These are treated as VALUE-range by default + for (name, output) in inputs { + // Skip if we already processed this port above + if node.inputs.iter().any(|p| &p.name == name) { + continue; + } + let size = output.list_len(); + if size == 0 { + return None; + } + max_size = max_size.max(size); + } + + Some(max_size) +} + +/// Build inputs for a single iteration with wrapping. +fn build_iteration_inputs( + inputs: &HashMap, + node: &Node, + iteration: usize, +) -> HashMap { + let mut result = HashMap::new(); + + for (name, output) in inputs { + // Check if there's a port definition for this input + let port = node.inputs.iter().find(|p| &p.name == name); + let is_list_range = port.map_or(false, |p| p.range == PortRange::List); + + let value = if is_list_range { + output.clone() // Pass entire list for LIST-range ports + } else { + let list = output.to_value_list(); + if list.is_empty() { + NodeOutput::None + } else { + list[iteration % list.len()].clone() // Wrap + } + }; + result.insert(name.clone(), value); + } + result +} + +/// Combine results from multiple iterations. +fn collect_results(results: Vec) -> NodeOutput { + if results.is_empty() { + return NodeOutput::None; + } + if results.len() == 1 { + return results.into_iter().next().unwrap(); + } + + // Detect the type of results based on the first non-None element + let first_type = results.iter().find(|r| !matches!(r, NodeOutput::None)); + match first_type { + Some(NodeOutput::Float(_)) => { + let floats: Vec = results.into_iter().filter_map(|r| match r { + NodeOutput::Float(f) => Some(f), + NodeOutput::Int(i) => Some(i as f64), + _ => None, + }).collect(); + NodeOutput::Floats(floats) + } + Some(NodeOutput::Int(_)) => { + let ints: Vec = results.into_iter().filter_map(|r| match r { + NodeOutput::Int(i) => Some(i), + NodeOutput::Float(f) => Some(f as i64), + _ => None, + }).collect(); + NodeOutput::Ints(ints) + } + Some(NodeOutput::String(_)) => { + let strings: Vec = results.into_iter().filter_map(|r| match r { + NodeOutput::String(s) => Some(s), + _ => None, + }).collect(); + NodeOutput::Strings(strings) + } + Some(NodeOutput::Boolean(_)) => { + let bools: Vec = results.into_iter().filter_map(|r| match r { + NodeOutput::Boolean(b) => Some(b), + _ => None, + }).collect(); + NodeOutput::Booleans(bools) + } + Some(NodeOutput::Point(_)) => { + let points: Vec = results.into_iter().filter_map(|r| match r { + NodeOutput::Point(p) => Some(p), + _ => None, + }).collect(); + NodeOutput::Points(points) + } + Some(NodeOutput::Color(_)) => { + let colors: Vec = results.into_iter().filter_map(|r| match r { + NodeOutput::Color(c) => Some(c), + _ => None, + }).collect(); + NodeOutput::Colors(colors) + } + Some(NodeOutput::DataRow(_)) => { + let rows: Vec> = results.into_iter().filter_map(|r| match r { + NodeOutput::DataRow(row) => Some(row), + _ => None, + }).collect(); + NodeOutput::DataRows(rows) + } + _ => { + // Default: collect as Paths (geometry operations) + let paths: Vec = results.into_iter() + .flat_map(|r| r.to_paths()) + .collect(); + if paths.is_empty() { + NodeOutput::None + } else { + NodeOutput::Paths(paths) + } + } + } +} + +/// Evaluate a single node with cancellation support. +fn evaluate_node_cancellable( + network: &Node, + node_name: &str, + cache: &mut HashMap, + cancel_token: &CancellationToken, + port: &Arc, + project_context: &ProjectContext, +) -> EvalResult { + // Check cancellation before starting this node + if cancel_token.is_cancelled() { + return Err(EvalError::Cancelled); + } + + // Check cache first + if let Some(result) = cache.get(node_name) { + return result.clone(); + } + + // Find the node + let node = match network.child(node_name) { + Some(n) => n, + None => return Err(EvalError::NodeNotFound(node_name.to_string())), + }; + + // Collect input values for this node + let mut inputs: HashMap = HashMap::new(); + + // For each input port, check if there are connections + for node_port in &node.inputs { + // Check cancellation during input collection + if cancel_token.is_cancelled() { + return Err(EvalError::Cancelled); + } + + // Get ALL connections to this port (for merge/combine operations) + let connections: Vec<_> = network.connections + .iter() + .filter(|c| c.input_node == node_name && c.input_port == node_port.name) + .collect(); + + if connections.is_empty() { + // No connections - use the port's default value + inputs.insert(node_port.name.clone(), value_to_output(&node_port.value)); + } else if connections.len() == 1 { + // Single connection - evaluate and use directly + let upstream_output = evaluate_node_cancellable( + network, + &connections[0].output_node, + cache, + cancel_token, + port, + project_context, + )?; + inputs.insert(node_port.name.clone(), upstream_output); + } else { + // Multiple connections - collect all outputs as paths + let mut all_paths: Vec = Vec::new(); + for conn in connections { + let upstream_output = evaluate_node_cancellable( + network, + &conn.output_node, + cache, + cancel_token, + port, + project_context, + )?; + all_paths.extend(upstream_output.to_paths()); + } + inputs.insert(node_port.name.clone(), NodeOutput::Paths(all_paths)); + } + } + + // Also collect inputs from connections that don't have corresponding port definitions + for conn in &network.connections { + if conn.input_node == node_name && !inputs.contains_key(&conn.input_port) { + // Check cancellation + if cancel_token.is_cancelled() { + return Err(EvalError::Cancelled); + } + + // Check if there are multiple connections to this port + let all_conns: Vec<_> = network.connections + .iter() + .filter(|c| c.input_node == node_name && c.input_port == conn.input_port) + .collect(); + + if all_conns.len() == 1 { + let upstream_output = evaluate_node_cancellable( + network, + &conn.output_node, + cache, + cancel_token, + port, + project_context, + )?; + inputs.insert(conn.input_port.clone(), upstream_output); + } else { + // Multiple connections - collect all outputs as paths + let mut all_paths: Vec = Vec::new(); + for c in all_conns { + let upstream_output = evaluate_node_cancellable( + network, + &c.output_node, + cache, + cancel_token, + port, + project_context, + )?; + all_paths.extend(upstream_output.to_paths()); + } + inputs.insert(conn.input_port.clone(), NodeOutput::Paths(all_paths)); + } + } + } + + // Determine iteration count for list matching + let iteration_count = compute_iteration_count(&inputs, node); + + let result = match iteration_count { + None => { + // Empty list input: still call execute_node to detect missing required inputs + execute_node(node, &inputs, port, project_context) + } + Some(1) => execute_node(node, &inputs, port, project_context), // Single iteration (optimization) + Some(count) => { + // Multiple iterations: list matching with cancellation checks + let mut results = Vec::with_capacity(count); + for i in 0..count { + // Check cancellation at each iteration boundary + if cancel_token.is_cancelled() { + return Err(EvalError::Cancelled); + } + + let iter_inputs = build_iteration_inputs(&inputs, node, i); + let result = execute_node(node, &iter_inputs, port, project_context)?; + results.push(result); + } + Ok(collect_results(results)) + } + }; + + // Cache and return + cache.insert(node_name.to_string(), result.clone()); + result +} + +/// Evaluate a single node, recursively evaluating its dependencies. +fn evaluate_node( + network: &Node, + node_name: &str, + cache: &mut HashMap, + port: &Arc, + project_context: &ProjectContext, +) -> EvalResult { + // Check cache first + if let Some(result) = cache.get(node_name) { + return result.clone(); + } + + // Find the node + let node = match network.child(node_name) { + Some(n) => n, + None => return Err(EvalError::NodeNotFound(node_name.to_string())), + }; + + // Collect input values for this node + let mut inputs: HashMap = HashMap::new(); + + // For each input port, check if there are connections + for node_port in &node.inputs { + // Get ALL connections to this port (for merge/combine operations) + let connections: Vec<_> = network.connections + .iter() + .filter(|c| c.input_node == node_name && c.input_port == node_port.name) + .collect(); + + if connections.is_empty() { + // No connections - use the port's default value + inputs.insert(node_port.name.clone(), value_to_output(&node_port.value)); + } else if connections.len() == 1 { + // Single connection - evaluate and use directly + let upstream_output = evaluate_node(network, &connections[0].output_node, cache, port, project_context)?; + inputs.insert(node_port.name.clone(), upstream_output); + } else { + // Multiple connections - collect all outputs as paths + let mut all_paths: Vec = Vec::new(); + for conn in connections { + let upstream_output = evaluate_node(network, &conn.output_node, cache, port, project_context)?; + all_paths.extend(upstream_output.to_paths()); + } + inputs.insert(node_port.name.clone(), NodeOutput::Paths(all_paths)); + } + } + + // Also collect inputs from connections that don't have corresponding port definitions + // This handles nodes loaded from ndbx files that may not have all ports defined + for conn in &network.connections { + if conn.input_node == node_name && !inputs.contains_key(&conn.input_port) { + // Check if there are multiple connections to this port + let all_conns: Vec<_> = network.connections + .iter() + .filter(|c| c.input_node == node_name && c.input_port == conn.input_port) + .collect(); + + if all_conns.len() == 1 { + let upstream_output = evaluate_node(network, &conn.output_node, cache, port, project_context)?; + inputs.insert(conn.input_port.clone(), upstream_output); + } else { + // Multiple connections - collect all outputs as paths + let mut all_paths: Vec = Vec::new(); + for c in all_conns { + let upstream_output = evaluate_node(network, &c.output_node, cache, port, project_context)?; + all_paths.extend(upstream_output.to_paths()); + } + inputs.insert(conn.input_port.clone(), NodeOutput::Paths(all_paths)); + } + } + } + + // Determine iteration count for list matching + let iteration_count = compute_iteration_count(&inputs, node); + + let result = match iteration_count { + None => { + // Empty list input: still call execute_node to detect missing required inputs + // This ensures nodes that require inputs produce proper errors + execute_node(node, &inputs, port, project_context) + } + Some(1) => execute_node(node, &inputs, port, project_context), // Single iteration (optimization) + Some(count) => { + // Multiple iterations: list matching + let mut results = Vec::with_capacity(count); + for i in 0..count { + let iter_inputs = build_iteration_inputs(&inputs, node, i); + let result = execute_node(node, &iter_inputs, port, project_context)?; + results.push(result); + } + Ok(collect_results(results)) + } + }; + + // Cache and return + cache.insert(node_name.to_string(), result.clone()); + result +} + +/// Convert a Value to a NodeOutput. +fn value_to_output(value: &Value) -> NodeOutput { + match value { + Value::Float(f) => NodeOutput::Float(*f), + Value::Int(i) => NodeOutput::Int(*i), + Value::String(s) => NodeOutput::String(s.clone()), + Value::Boolean(b) => NodeOutput::Boolean(*b), + Value::Point(p) => NodeOutput::Point(*p), + Value::Color(c) => NodeOutput::Color(*c), + Value::Geometry(_) => NodeOutput::None, // Will be filled by connections + Value::List(_) => NodeOutput::None, // TODO: handle lists + Value::Null => NodeOutput::None, + Value::Path(p) => NodeOutput::Path(p.clone()), + Value::Map(map) => { + let row: HashMap = map.iter().map(|(k, v)| { + let dv = match v { + Value::Float(f) => DataValue::Float(*f), + Value::Int(i) => DataValue::Float(*i as f64), + _ => DataValue::String(format!("{:?}", v)), + }; + (k.clone(), dv) + }).collect(); + NodeOutput::DataRow(row) + } + } +} + +/// Get a float input value. +fn get_float(inputs: &HashMap, name: &str, default: f64) -> f64 { + match inputs.get(name) { + Some(NodeOutput::Float(f)) => *f, + Some(NodeOutput::Int(i)) => *i as f64, + _ => default, + } +} + +/// Get an integer input value. +fn get_int(inputs: &HashMap, name: &str, default: i64) -> i64 { + match inputs.get(name) { + Some(NodeOutput::Int(i)) => *i, + Some(NodeOutput::Float(f)) => *f as i64, + _ => default, + } +} + +/// Get a point input value. +fn get_point(inputs: &HashMap, name: &str, default: Point) -> Point { + match inputs.get(name) { + Some(NodeOutput::Point(p)) => *p, + Some(NodeOutput::Points(pts)) if !pts.is_empty() => pts[0], // Fallback for safety + _ => default, + } +} + +/// Get a color input value. +fn get_color(inputs: &HashMap, name: &str, default: Color) -> Color { + match inputs.get(name) { + Some(NodeOutput::Color(c)) => *c, + _ => default, + } +} + +/// Get a path input value. +fn get_path(inputs: &HashMap, name: &str) -> Option { + match inputs.get(name) { + Some(NodeOutput::Path(p)) => Some(p.clone()), + Some(NodeOutput::Paths(ps)) if !ps.is_empty() => Some(ps[0].clone()), + _ => None, + } +} + +/// Get paths input value (for merge/combine operations). +fn get_paths(inputs: &HashMap, name: &str) -> Vec { + match inputs.get(name) { + Some(NodeOutput::Path(p)) => vec![p.clone()], + Some(NodeOutput::Paths(ps)) => ps.clone(), + _ => Vec::new(), + } +} + +/// Get a boolean input value. +fn get_bool(inputs: &HashMap, name: &str, default: bool) -> bool { + match inputs.get(name) { + Some(NodeOutput::Boolean(b)) => *b, + _ => default, + } +} + +/// Get a string input value. +fn get_string(inputs: &HashMap, name: &str, default: &str) -> String { + match inputs.get(name) { + Some(NodeOutput::String(s)) => s.clone(), + Some(NodeOutput::Float(f)) => format!("{}", f), + Some(NodeOutput::Int(i)) => format!("{}", i), + _ => default.to_string(), + } +} + +/// Get a list of float values from input. +fn get_floats(inputs: &HashMap, name: &str) -> Vec { + match inputs.get(name) { + Some(NodeOutput::Floats(fs)) => fs.clone(), + Some(NodeOutput::Float(f)) => vec![*f], + Some(NodeOutput::Ints(is)) => is.iter().map(|i| *i as f64).collect(), + Some(NodeOutput::Int(i)) => vec![*i as f64], + _ => Vec::new(), + } +} + +/// Get a list of boolean values from input. +fn get_booleans(inputs: &HashMap, name: &str) -> Vec { + match inputs.get(name) { + Some(NodeOutput::Booleans(bs)) => bs.clone(), + Some(NodeOutput::Boolean(b)) => vec![*b], + _ => Vec::new(), + } +} + +/// Get a list of data rows from input. +fn get_data_rows(inputs: &HashMap, name: &str) -> Vec> { + match inputs.get(name) { + Some(NodeOutput::DataRows(rs)) => rs.clone(), + Some(NodeOutput::DataRow(r)) => vec![r.clone()], + _ => Vec::new(), + } +} + +/// Convert any NodeOutput to a list of DataValues (for make_table inputs). +fn get_as_data_values(inputs: &HashMap, name: &str) -> Vec { + match inputs.get(name) { + Some(NodeOutput::Floats(fs)) => fs.iter().map(|f| DataValue::Float(*f)).collect(), + Some(NodeOutput::Float(f)) => vec![DataValue::Float(*f)], + Some(NodeOutput::Ints(is)) => is.iter().map(|i| DataValue::Float(*i as f64)).collect(), + Some(NodeOutput::Int(i)) => vec![DataValue::Float(*i as f64)], + Some(NodeOutput::Strings(ss)) => ss.iter().map(|s| DataValue::String(s.clone())).collect(), + Some(NodeOutput::String(s)) => vec![DataValue::String(s.clone())], + Some(NodeOutput::Booleans(bs)) => bs.iter().map(|b| DataValue::String(b.to_string())).collect(), + Some(NodeOutput::Boolean(b)) => vec![DataValue::String(b.to_string())], + Some(NodeOutput::Points(pts)) => pts.iter().map(|p| DataValue::String(format!("{:.2}, {:.2}", p.x, p.y))).collect(), + Some(NodeOutput::Point(p)) => vec![DataValue::String(format!("{:.2}, {:.2}", p.x, p.y))], + _ => Vec::new(), + } +} + +/// Require a path input value, returning an error if not present. +fn require_path(inputs: &HashMap, node_name: &str, port_name: &str) -> Result { + match inputs.get(port_name) { + Some(NodeOutput::Path(p)) => Ok(p.clone()), + Some(NodeOutput::Paths(ps)) if !ps.is_empty() => Ok(ps[0].clone()), + _ => Err(EvalError::PortNotFound { + node: node_name.to_string(), + port: port_name.to_string(), + }), + } +} + +/// Require paths input value (for merge/combine operations), returning an error if not present. +fn require_paths(inputs: &HashMap, node_name: &str, port_name: &str) -> Result, EvalError> { + match inputs.get(port_name) { + Some(NodeOutput::Path(p)) => Ok(vec![p.clone()]), + Some(NodeOutput::Paths(ps)) if !ps.is_empty() => Ok(ps.clone()), + _ => Err(EvalError::PortNotFound { + node: node_name.to_string(), + port: port_name.to_string(), + }), + } +} + +/// Execute a node and return its output. +fn execute_node( + node: &Node, + inputs: &HashMap, + port: &Arc, + project_context: &ProjectContext, +) -> EvalResult { + // Get the function name (prototype determines what the node does) + let proto = match &node.prototype { + Some(p) => p.as_str(), + None => return Ok(NodeOutput::None), + }; + + let node_name = &node.name; + + match proto { + // Geometry generators + // Note: These use "position" (Point) as per corevector.ndbx library definition + "corevector.ellipse" => { + let position = get_point(inputs, "position", Point::ZERO); + let width = get_float(inputs, "width", 100.0); + let height = get_float(inputs, "height", 100.0); + let path = ops::ellipse(position, width, height); + Ok(NodeOutput::Path(path)) + } + "corevector.rect" => { + let position = get_point(inputs, "position", Point::ZERO); + let width = get_float(inputs, "width", 100.0); + let height = get_float(inputs, "height", 100.0); + // Note: corevector.ndbx uses "roundness" (Point), not rx/ry + let roundness = get_point(inputs, "roundness", Point::ZERO); + let path = ops::rect(position, width, height, roundness); + Ok(NodeOutput::Path(path)) + } + "corevector.line" => { + let p1 = get_point(inputs, "point1", Point::ZERO); + let p2 = get_point(inputs, "point2", Point::new(100.0, 100.0)); + let points = get_int(inputs, "points", 2).max(0) as u32; + let path = ops::line(p1, p2, points); + Ok(NodeOutput::Path(path)) + } + "corevector.polygon" => { + let position = get_point(inputs, "position", Point::ZERO); + let radius = get_float(inputs, "radius", 50.0); + let sides = get_int(inputs, "sides", 6).max(0) as u32; + let align = get_bool(inputs, "align", true); + let path = ops::polygon(position, radius, sides, align); + Ok(NodeOutput::Path(path)) + } + "corevector.star" => { + let position = get_point(inputs, "position", Point::ZERO); + let points = get_int(inputs, "points", 5).max(0) as u32; + let outer = get_float(inputs, "outer", 50.0); + let inner = get_float(inputs, "inner", 25.0); + let path = ops::star(position, points, outer, inner); + Ok(NodeOutput::Path(path)) + } + "corevector.arc" => { + let position = get_point(inputs, "position", Point::ZERO); + let width = get_float(inputs, "width", 100.0); + let height = get_float(inputs, "height", 100.0); + // Note: corevector.ndbx uses "start_angle" (underscore), not "startAngle" + let start_angle = get_float(inputs, "start_angle", 0.0); + let degrees = get_float(inputs, "degrees", 90.0); + let arc_type = get_string(inputs, "type", "pie"); + let path = ops::arc(position, width, height, start_angle, degrees, &arc_type); + Ok(NodeOutput::Path(path)) + } + "corevector.textpath" => { + let text = get_string(inputs, "text", "hello"); + let font_name = get_string(inputs, "font_name", "Verdana"); + let font_size = get_float(inputs, "font_size", 24.0); + let position = get_point(inputs, "position", Point::ZERO); + let path = font::text_to_path(&text, &font_name, font_size, position) + .map_err(|e| EvalError::ProcessingError(e.to_string()))?; + Ok(NodeOutput::Path(path)) + } + + // Filters/transforms + "corevector.colorize" => { + let shape = require_path(inputs, node_name, "shape")?; + let fill = get_color(inputs, "fill", Color::WHITE); + let stroke = get_color(inputs, "stroke", Color::BLACK); + let stroke_width = get_float(inputs, "strokeWidth", 1.0); + let path = ops::colorize(&shape, fill, stroke, stroke_width); + Ok(NodeOutput::Path(path)) + } + "corevector.translate" => { + let shape = require_path(inputs, node_name, "shape")?; + let offset = get_point(inputs, "translate", Point::ZERO); + let path = ops::translate(&shape, offset); + Ok(NodeOutput::Path(path)) + } + "corevector.rotate" => { + let shape = require_path(inputs, node_name, "shape")?; + let angle = get_float(inputs, "angle", 0.0); + let origin = get_point(inputs, "origin", Point::ZERO); + let path = ops::rotate(&shape, angle, origin); + Ok(NodeOutput::Path(path)) + } + "corevector.scale" => { + let shape = require_path(inputs, node_name, "shape")?; + let scale = get_point(inputs, "scale", Point::new(100.0, 100.0)); + let origin = get_point(inputs, "origin", Point::ZERO); + let path = ops::scale(&shape, scale, origin); + Ok(NodeOutput::Path(path)) + } + "corevector.align" => { + let shape = require_path(inputs, node_name, "shape")?; + let position = get_point(inputs, "position", Point::ZERO); + let halign = get_string(inputs, "halign", "center"); + let valign = get_string(inputs, "valign", "middle"); + let path = ops::align_str(&shape, position, &halign, &valign); + Ok(NodeOutput::Path(path)) + } + "corevector.fit" => { + let shape = require_path(inputs, node_name, "shape")?; + // Note: corevector.ndbx uses "position" (Point) and "keep_proportions" (underscore) + let position = get_point(inputs, "position", Point::ZERO); + let width = get_float(inputs, "width", 100.0); + let height = get_float(inputs, "height", 100.0); + let keep_proportions = get_bool(inputs, "keep_proportions", true); + let path = ops::fit(&shape, position, width, height, keep_proportions); + Ok(NodeOutput::Path(path)) + } + "corevector.copy" => { + let shape = require_path(inputs, node_name, "shape")?; + let copies = get_int(inputs, "copies", 1).max(0) as u32; + let order = ops::CopyOrder::from_str(&get_string(inputs, "order", "tsr")); + // Note: corevector.ndbx uses "translate" (Point) and "scale" (Point) + let translate = get_point(inputs, "translate", Point::ZERO); + let rotate = get_float(inputs, "rotate", 0.0); + let scale = get_point(inputs, "scale", Point::new(100.0, 100.0)); + let paths = ops::copy(&shape, copies, order, translate, rotate, scale); + Ok(NodeOutput::Paths(paths)) + } + + // Combine operations + "corevector.merge" | "corevector.combine" => { + // Merge/combine takes multiple shapes and combines them + let shapes = get_paths(inputs, "shapes"); + if shapes.is_empty() { + // Try "shape" port as fallback + let shape = get_paths(inputs, "shape"); + if shape.is_empty() { + return Ok(NodeOutput::None); + } + return Ok(NodeOutput::Paths(shape)); + } + Ok(NodeOutput::Paths(shapes)) + } + + // List combine - combines multiple lists into one + "list.combine" => { + let mut all_paths: Vec = Vec::new(); + // Collect from list1 through list5 + for port_name in ["list1", "list2", "list3", "list4", "list5"] { + let paths = get_paths(inputs, port_name); + all_paths.extend(paths); + } + if all_paths.is_empty() { + Ok(NodeOutput::None) + } else { + Ok(NodeOutput::Paths(all_paths)) + } + } + + // Resample + "corevector.resample" => { + let shape = require_path(inputs, node_name, "shape")?; + let method = get_string(inputs, "method", "length"); + let path = if method == "length" { + let length = get_float(inputs, "length", 10.0).max(1.0); + ops::resample_by_length(&shape, length) + } else { + let points = get_int(inputs, "points", 20).max(0) as usize; + ops::resample(&shape, points) + }; + Ok(NodeOutput::Path(path)) + } + + // Wiggle + "corevector.wiggle" => { + let shape = require_path(inputs, node_name, "shape")?; + let scope = ops::WiggleScope::from_str(&get_string(inputs, "scope", "points")); + // Note: corevector.ndbx uses "offset" (Point), not offsetX/offsetY + let offset = get_point(inputs, "offset", Point::new(10.0, 10.0)); + let seed = get_int(inputs, "seed", 0) as u64; + let path = ops::wiggle(&shape, scope, offset, seed); + Ok(NodeOutput::Path(path)) + } + + // Connect points + "corevector.connect" => { + // Get points from input + let closed = get_bool(inputs, "closed", false); + match inputs.get("points") { + Some(NodeOutput::Points(pts)) => { + let path = ops::connect(pts, closed); + Ok(NodeOutput::Path(path)) + } + _ => Ok(NodeOutput::None), + } + } + + // Grid of points + "corevector.grid" => { + let columns = get_int(inputs, "columns", 3).max(0) as u32; + let rows = get_int(inputs, "rows", 3).max(0) as u32; + let width = get_float(inputs, "width", 100.0); + let height = get_float(inputs, "height", 100.0); + // Note: corevector.ndbx uses "position" (Point), not x/y + let position = get_point(inputs, "position", Point::ZERO); + let points = ops::grid(columns, rows, width, height, position); + Ok(NodeOutput::Points(points)) + } + + // Make point + "corevector.point" | "corevector.makePoint" | "corevector.make_point" => { + let x = get_float(inputs, "x", 0.0); + let y = get_float(inputs, "y", 0.0); + Ok(NodeOutput::Point(Point::new(x, y))) + } + + // Reflect + "corevector.reflect" => { + let shape = require_path(inputs, node_name, "shape")?; + // Note: corevector.ndbx uses "position" (Point), "angle", "keep_original" + let position = get_point(inputs, "position", Point::ZERO); + let angle = get_float(inputs, "angle", 0.0); + let keep_original = get_bool(inputs, "keep_original", true); + let geometry = ops::reflect(&shape, position, angle, keep_original); + Ok(NodeOutput::Paths(ops::ungroup(&geometry))) + } + + // Skew + "corevector.skew" => { + let shape = require_path(inputs, node_name, "shape")?; + // Note: corevector.ndbx uses "skew" (Point), "origin" (Point) + let skew = get_point(inputs, "skew", Point::ZERO); + let origin = get_point(inputs, "origin", Point::ZERO); + let path = ops::skew(&shape, skew, origin); + Ok(NodeOutput::Path(path)) + } + + // Snap to grid + "corevector.snap" => { + let shape = require_path(inputs, node_name, "shape")?; + // Note: corevector.ndbx uses "distance" (float), "strength" (float), "position" (Point) + let distance = get_float(inputs, "distance", 10.0); + let strength = get_float(inputs, "strength", 1.0); + let position = get_point(inputs, "position", Point::ZERO); + let path = ops::snap(&shape, distance, strength, position); + Ok(NodeOutput::Path(path)) + } + + // Point on path + "corevector.point_on_path" => { + let shape = require_path(inputs, node_name, "shape")?; + let t = get_float(inputs, "t", 0.0); + // Range varies; convert from 0-100 percentage to 0-1 if needed + let t_normalized = if t > 1.0 { t / 100.0 } else { t }; + let point = ops::point_on_path(&shape, t_normalized); + Ok(NodeOutput::Point(point)) + } + + // Centroid + "corevector.centroid" => { + let shape = require_path(inputs, node_name, "shape")?; + let point = ops::centroid(&shape); + Ok(NodeOutput::Point(point)) + } + + // Line from angle + "corevector.line_angle" => { + let position = get_point(inputs, "position", Point::ZERO); + let angle = get_float(inputs, "angle", 0.0); + let distance = get_float(inputs, "distance", 100.0); + let points = get_int(inputs, "points", 2).max(0) as u32; + let path = ops::line_angle(position, angle, distance, points); + Ok(NodeOutput::Path(path)) + } + + // Quad curve + "corevector.quad_curve" => { + let point1 = get_point(inputs, "point1", Point::ZERO); + let point2 = get_point(inputs, "point2", Point::new(100.0, 100.0)); + let t = get_float(inputs, "t", 0.5); + let distance = get_float(inputs, "distance", 50.0); + let path = ops::quad_curve(point1, point2, t, distance); + Ok(NodeOutput::Path(path)) + } + + // Scatter points + "corevector.scatter" => { + let shape = require_path(inputs, node_name, "shape")?; + let amount = get_int(inputs, "amount", 10) as usize; + let seed = get_int(inputs, "seed", 0) as u64; + let points = ops::scatter(&shape, amount, seed); + Ok(NodeOutput::Points(points)) + } + + // Stack + "corevector.stack" => { + let shapes = require_paths(inputs, node_name, "shapes")?; + let direction = get_string(inputs, "direction", "east"); + let margin = get_float(inputs, "margin", 0.0); + let dir = match direction.as_str() { + "north" => ops::StackDirection::North, + "south" => ops::StackDirection::South, + "west" => ops::StackDirection::West, + _ => ops::StackDirection::East, + }; + let paths = ops::stack(&shapes, dir, margin); + Ok(NodeOutput::Paths(paths)) + } + + // Distribute + "corevector.distribute" => { + let shapes = require_paths(inputs, node_name, "shapes")?; + let horizontal = get_string(inputs, "horizontal", "none"); + let vertical = get_string(inputs, "vertical", "none"); + let h = ops::HDistribute::from_str(&horizontal); + let v = ops::VDistribute::from_str(&vertical); + let paths = ops::distribute(&shapes, h, v); + Ok(NodeOutput::Paths(paths)) + } + + // Freehand path + "corevector.freehand" => { + let path_string = get_string(inputs, "path", ""); + let path = ops::freehand(&path_string); + Ok(NodeOutput::Path(path)) + } + + // Link shapes + "corevector.link" => { + let shape1 = require_path(inputs, node_name, "shape1")?; + let shape2 = require_path(inputs, node_name, "shape2")?; + let orientation = get_string(inputs, "orientation", "horizontal"); + let horizontal = orientation == "horizontal"; + let path = ops::link(&shape1, &shape2, horizontal); + Ok(NodeOutput::Path(path)) + } + + // Group + "corevector.group" => { + let shapes = get_paths(inputs, "shapes"); + let geometry = ops::group(&shapes); + Ok(NodeOutput::Paths(ops::ungroup(&geometry))) + } + + // Ungroup + "corevector.ungroup" => { + // Ungroup expects a Geometry, but we work with paths + let shapes = get_paths(inputs, "geometry"); + if shapes.is_empty() { + let shape = get_paths(inputs, "shape"); + return Ok(NodeOutput::Paths(shape)); + } + Ok(NodeOutput::Paths(shapes)) + } + + // Fit to another shape + "corevector.fit_to" => { + let shape = require_path(inputs, node_name, "shape")?; + let bounding = require_path(inputs, node_name, "bounding")?; + let keep_proportions = get_bool(inputs, "keep_proportions", true); + let path = ops::fit_to(&shape, &bounding, keep_proportions); + Ok(NodeOutput::Path(path)) + } + + // Delete + "corevector.delete" => { + let shape = require_path(inputs, node_name, "shape")?; + let bounding = match get_path(inputs, "bounding") { + Some(p) => p, + None => return Ok(NodeOutput::Path(shape)), + }; + let scope = get_string(inputs, "scope", "points"); + let delete_scope = match scope.as_str() { + "paths" => ops::DeleteScope::Paths, + _ => ops::DeleteScope::Points, + }; + let operation = get_string(inputs, "operation", "selected"); + let delete_inside = operation == "selected"; + let path = ops::delete(&shape, &bounding, delete_scope, delete_inside); + Ok(NodeOutput::Path(path)) + } + + // Sort + "corevector.sort" => { + let shapes = require_paths(inputs, node_name, "shapes")?; + let order_by = get_string(inputs, "order_by", "x"); + let sort_by = match order_by.as_str() { + "y" => ops::SortBy::Y, + "distance" => ops::SortBy::Distance, + "angle" => ops::SortBy::Angle, + _ => ops::SortBy::X, + }; + let position = get_point(inputs, "position", Point::ZERO); + let paths = ops::sort_paths(&shapes, sort_by, position); + Ok(NodeOutput::Paths(paths)) + } + + // Import SVG + "corevector.import_svg" => { + let file_path = get_string(inputs, "file", ""); + let centered = get_bool(inputs, "centered", true); + let position = get_point(inputs, "position", Point::ZERO); + + // Empty path returns empty geometry + if file_path.is_empty() { + return Ok(NodeOutput::None); + } + + // Read the SVG file through the Port system (sandboxed) + let svg_content = match port.read_text_file(project_context, &file_path) { + Ok(content) => content, + Err(e) => { + log::warn!("SVG import: {}", e); + return Ok(NodeOutput::None); + } + }; + + // Parse the SVG content + match ops::import_svg(&svg_content, centered, position) { + Ok(geometry) => { + if geometry.is_empty() { + Ok(NodeOutput::None) + } else { + Ok(NodeOutput::Paths(geometry.paths)) + } + } + Err(e) => { + log::warn!("SVG parse error: {}", e); + Ok(NodeOutput::None) + } + } + } + + // ======================== + // Geometry: shape_on_path + // ======================== + "corevector.shape_on_path" => { + let shapes = get_paths(inputs, "shape"); + let path = require_path(inputs, node_name, "path")?; + let amount = get_int(inputs, "amount", 1) as usize; + let spacing = get_float(inputs, "spacing", 20.0); + let margin = get_float(inputs, "margin", 0.0); + let paths = ops::shape_on_path(&shapes, &path, amount, spacing, margin, true); + Ok(NodeOutput::Paths(paths)) + } + + // Geometry: null / doNothing + "corevector.null" => { + if let Some(path) = get_path(inputs, "shape") { + Ok(NodeOutput::Path(path)) + } else { + Ok(NodeOutput::None) + } + } + + // ======================== + // Math nodes (41) + // ======================== + + // Identity / variable nodes + "math.number" => { + Ok(NodeOutput::Float(get_float(inputs, "value", 0.0))) + } + "math.integer" => { + Ok(NodeOutput::Int(get_int(inputs, "value", 0))) + } + "math.boolean" => { + Ok(NodeOutput::Boolean(get_bool(inputs, "value", false))) + } + + // Arithmetic + "math.add" => { + let v1 = get_float(inputs, "value1", 0.0); + let v2 = get_float(inputs, "value2", 0.0); + Ok(NodeOutput::Float(ops::math::add(v1, v2))) + } + "math.subtract" => { + let v1 = get_float(inputs, "value1", 0.0); + let v2 = get_float(inputs, "value2", 0.0); + Ok(NodeOutput::Float(ops::math::subtract(v1, v2))) + } + "math.multiply" => { + let v1 = get_float(inputs, "value1", 0.0); + let v2 = get_float(inputs, "value2", 1.0); + Ok(NodeOutput::Float(ops::math::multiply(v1, v2))) + } + "math.divide" => { + let v1 = get_float(inputs, "value1", 0.0); + let v2 = get_float(inputs, "value2", 1.0); + if v2 == 0.0 { + return Err(EvalError::ProcessingError(format!("{}: Division by zero", node_name))); + } + Ok(NodeOutput::Float(ops::math::divide(v1, v2))) + } + "math.mod" => { + let v1 = get_float(inputs, "value1", 0.0); + let v2 = get_float(inputs, "value2", 1.0); + if v2 == 0.0 { + return Err(EvalError::ProcessingError(format!("{}: Modulo by zero", node_name))); + } + Ok(NodeOutput::Float(ops::math::modulo(v1, v2))) + } + + // Unary math + "math.negate" => { + Ok(NodeOutput::Float(ops::math::negate(get_float(inputs, "value", 0.0)))) + } + "math.abs" => { + Ok(NodeOutput::Float(ops::math::abs(get_float(inputs, "value", 0.0)))) + } + "math.sqrt" => { + Ok(NodeOutput::Float(ops::math::sqrt(get_float(inputs, "value", 0.0)))) + } + "math.pow" => { + let v1 = get_float(inputs, "value1", 0.0); + let v2 = get_float(inputs, "value2", 0.0); + Ok(NodeOutput::Float(ops::math::pow(v1, v2))) + } + "math.log" => { + let v = get_float(inputs, "value", 1.0); + if v == 0.0 { + return Err(EvalError::ProcessingError(format!("{}: Log of zero", node_name))); + } + Ok(NodeOutput::Float(ops::math::log(v))) + } + + // Rounding + "math.ceil" => { + Ok(NodeOutput::Float(ops::math::ceil(get_float(inputs, "value", 0.0)))) + } + "math.floor" => { + Ok(NodeOutput::Float(ops::math::floor(get_float(inputs, "value", 0.0)))) + } + "math.round" => { + Ok(NodeOutput::Int(ops::math::round(get_float(inputs, "value", 0.0)))) + } + + // Trigonometry + "math.sin" => { + Ok(NodeOutput::Float(ops::math::sin(get_float(inputs, "value", 0.0)))) + } + "math.cos" => { + Ok(NodeOutput::Float(ops::math::cos(get_float(inputs, "value", 0.0)))) + } + "math.radians" => { + Ok(NodeOutput::Float(ops::math::radians(get_float(inputs, "degrees", 0.0)))) + } + "math.degrees" => { + Ok(NodeOutput::Float(ops::math::degrees(get_float(inputs, "radians", 0.0)))) + } + + // Constants + "math.pi" => { + Ok(NodeOutput::Float(ops::math::pi())) + } + "math.e" => { + Ok(NodeOutput::Float(ops::math::e())) + } + + // Predicates + "math.even" => { + Ok(NodeOutput::Boolean(ops::math::even(get_float(inputs, "value", 0.0)))) + } + "math.odd" => { + Ok(NodeOutput::Boolean(ops::math::odd(get_float(inputs, "value", 0.0)))) + } + + // Comparison / logic + "math.compare" => { + let v1 = get_float(inputs, "value1", 0.0); + let v2 = get_float(inputs, "value2", 0.0); + let comparator = get_string(inputs, "comparator", "<"); + Ok(NodeOutput::Boolean(ops::math::compare(v1, v2, &comparator))) + } + "math.logical" => { + let b1 = get_bool(inputs, "boolean1", false); + let b2 = get_bool(inputs, "boolean2", false); + let comparator = get_string(inputs, "comparator", "or"); + Ok(NodeOutput::Boolean(ops::math::logic_operator(b1, b2, &comparator))) + } + + // Point math + "math.angle" => { + let p1 = get_point(inputs, "point1", Point::ZERO); + let p2 = get_point(inputs, "point2", Point::new(100.0, 100.0)); + Ok(NodeOutput::Float(ops::math::angle(p1, p2))) + } + "math.distance" => { + let p1 = get_point(inputs, "point1", Point::ZERO); + let p2 = get_point(inputs, "point2", Point::new(100.0, 100.0)); + Ok(NodeOutput::Float(ops::math::distance(p1, p2))) + } + "math.coordinates" => { + let position = get_point(inputs, "position", Point::ZERO); + let angle = get_float(inputs, "angle", 0.0); + let distance = get_float(inputs, "distance", 100.0); + Ok(NodeOutput::Point(ops::math::coordinates(position, angle, distance))) + } + "math.reflect" => { + let p1 = get_point(inputs, "point1", Point::ZERO); + let p2 = get_point(inputs, "point2", Point::new(100.0, 100.0)); + let angle = get_float(inputs, "angle", 0.0); + let distance = get_float(inputs, "distance", 1.0); + Ok(NodeOutput::Point(ops::math::reflect(p1, p2, angle, distance))) + } + + // Aggregation + "math.sum" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(ops::math::sum(&values))) + } + "math.average" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(ops::math::average(&values))) + } + "math.max" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(ops::math::max(&values))) + } + "math.min" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(ops::math::min(&values))) + } + + // Convert range + "math.convert_range" => { + let value = get_float(inputs, "value", 50.0); + let src_start = get_float(inputs, "source_start", 0.0); + let src_end = get_float(inputs, "source_end", 100.0); + let target_start = get_float(inputs, "target_start", 0.0); + let target_end = get_float(inputs, "target_end", 1.0); + let method = get_string(inputs, "method", "clamp"); + let overflow = ops::math::OverflowMethod::from_str(&method); + Ok(NodeOutput::Float(ops::math::convert_range( + value, src_start, src_end, target_start, target_end, overflow, + ))) + } + + // Wave + "math.wave" => { + let min = get_float(inputs, "min", 0.0); + let max = get_float(inputs, "max", 100.0); + let period = get_float(inputs, "period", 60.0); + let offset = get_float(inputs, "offset", 0.0); + let wave_type_str = get_string(inputs, "type", "sine"); + let wave_type = ops::math::WaveType::from_str(&wave_type_str); + Ok(NodeOutput::Float(ops::math::wave(min, max, period, offset, wave_type))) + } + + // List-returning math nodes + "math.make_numbers" => { + let s = get_string(inputs, "string", "11;22;33"); + let separator = get_string(inputs, "separator", ";"); + Ok(NodeOutput::Floats(ops::math::make_numbers(&s, &separator))) + } + "math.random_numbers" => { + let amount = get_int(inputs, "amount", 10) as usize; + let start = get_float(inputs, "start", 0.0); + let end = get_float(inputs, "end", 100.0); + let seed = get_int(inputs, "seed", 0) as u64; + Ok(NodeOutput::Floats(ops::math::random_numbers(amount, start, end, seed))) + } + "math.sample" => { + let amount = get_int(inputs, "amount", 10) as usize; + let start = get_float(inputs, "start", 0.0); + let end = get_float(inputs, "end", 100.0); + Ok(NodeOutput::Floats(ops::math::sample(amount, start, end))) + } + "math.range" => { + let start = get_float(inputs, "start", 0.0); + let end = get_float(inputs, "end", 10.0); + let step = get_float(inputs, "step", 1.0); + Ok(NodeOutput::Floats(ops::math::range(start, end, step))) + } + "math.running_total" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Floats(ops::math::running_total(&values))) + } + + // ======================== + // String nodes (21) + // ======================== + + "string.string" => { + Ok(NodeOutput::String(get_string(inputs, "value", ""))) + } + "string.length" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::Int(ops::string::length(&s) as i64)) + } + "string.word_count" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::Int(ops::string::word_count(&s) as i64)) + } + "string.concatenate" => { + let s1 = get_string(inputs, "string1", ""); + let s2 = get_string(inputs, "string2", ""); + let s3 = get_string(inputs, "string3", ""); + let s4 = get_string(inputs, "string4", ""); + let s5 = get_string(inputs, "string5", ""); + let s6 = get_string(inputs, "string6", ""); + let s7 = get_string(inputs, "string7", ""); + let parts: Vec<&str> = [&s1, &s2, &s3, &s4, &s5, &s6, &s7] + .iter().map(|s| s.as_str()).collect(); + Ok(NodeOutput::String(ops::string::concatenate(&parts))) + } + "string.change_case" => { + let s = get_string(inputs, "string", ""); + let method = get_string(inputs, "method", "uppercase"); + let case_method = ops::string::CaseMethod::from_str(&method); + Ok(NodeOutput::String(ops::string::change_case(&s, case_method))) + } + "string.format_number" => { + let value = get_float(inputs, "value", 0.0); + let format = get_string(inputs, "format", "%.2f"); + Ok(NodeOutput::String(ops::string::format_number(value, &format))) + } + "string.trim" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::String(ops::string::trim(&s))) + } + "string.replace" => { + let s = get_string(inputs, "string", ""); + let old_val = get_string(inputs, "old", ""); + let new_val = get_string(inputs, "new", ""); + Ok(NodeOutput::String(ops::string::replace(&s, &old_val, &new_val))) + } + "string.sub_string" => { + let s = get_string(inputs, "string", ""); + let start = get_int(inputs, "start", 0); + let end = get_int(inputs, "end", 4); + let end_offset = get_bool(inputs, "end_offset", false); + Ok(NodeOutput::String(ops::string::sub_string(&s, start, end, end_offset))) + } + "string.character_at" => { + let s = get_string(inputs, "string", ""); + let index = get_int(inputs, "index", 0); + Ok(NodeOutput::String(ops::string::character_at(&s, index))) + } + "string.as_binary_string" => { + let s = get_string(inputs, "string", ""); + let digit_sep = get_string(inputs, "digit_separator", ""); + let byte_sep = get_string(inputs, "byte_separator", " "); + Ok(NodeOutput::String(ops::string::as_binary_string(&s, &digit_sep, &byte_sep))) + } + + // String boolean tests + "string.contains" => { + let s = get_string(inputs, "string", ""); + let value = get_string(inputs, "contains", ""); + Ok(NodeOutput::Boolean(ops::string::contains(&s, &value))) + } + "string.ends_with" => { + let s = get_string(inputs, "string", ""); + let value = get_string(inputs, "ends_with", ""); + Ok(NodeOutput::Boolean(ops::string::ends_with(&s, &value))) + } + "string.starts_with" => { + let s = get_string(inputs, "string", ""); + let value = get_string(inputs, "starts_with", ""); + Ok(NodeOutput::Boolean(ops::string::starts_with(&s, &value))) + } + "string.equals" => { + let s = get_string(inputs, "string", ""); + let value = get_string(inputs, "equals", ""); + let case_sensitive = get_bool(inputs, "case_sensitive", false); + Ok(NodeOutput::Boolean(ops::string::equal(&s, &value, case_sensitive))) + } + + // String list-returning nodes + "string.make_strings" => { + let s = get_string(inputs, "string", "Alpha;Beta;Gamma"); + let separator = get_string(inputs, "separator", ";"); + Ok(NodeOutput::Strings(ops::string::make_strings(&s, &separator))) + } + "string.characters" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::Strings(ops::string::characters(&s))) + } + "string.random_character" => { + let chars = get_string(inputs, "characters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"); + let amount = get_int(inputs, "amount", 10) as usize; + let seed = get_int(inputs, "seed", 0) as u64; + Ok(NodeOutput::Strings(ops::string::random_character(&chars, amount, seed))) + } + "string.as_binary_list" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::Strings(ops::string::as_binary_list(&s))) + } + "string.as_number_list" => { + let s = get_string(inputs, "string", ""); + let radix = get_int(inputs, "radix", 10).max(0) as u32; + let padding = get_bool(inputs, "padding", true); + Ok(NodeOutput::Strings(ops::string::as_number_list(&s, radix, padding))) + } + + // ======================== + // List nodes (18 remaining) + // ======================== + + "list.count" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Int(v.len() as i64)), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Int(v.len() as i64)), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Int(v.len() as i64)), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Int(v.len() as i64)), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Int(v.len() as i64)), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Int(v.len() as i64)), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Int(v.len() as i64)), + _ => Ok(NodeOutput::Int(0)), + } + } + + "list.first" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(v.first().map(|p| NodeOutput::Path(p.clone())).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Points(v)) => Ok(v.first().map(|p| NodeOutput::Point(*p)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Floats(v)) => Ok(v.first().map(|f| NodeOutput::Float(*f)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Ints(v)) => Ok(v.first().map(|i| NodeOutput::Int(*i)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Strings(v)) => Ok(v.first().map(|s| NodeOutput::String(s.clone())).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Booleans(v)) => Ok(v.first().map(|b| NodeOutput::Boolean(*b)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Colors(v)) => Ok(v.first().map(|c| NodeOutput::Color(*c)).unwrap_or(NodeOutput::None)), + _ => Ok(NodeOutput::None), + } + } + + "list.second" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(v.get(1).map(|p| NodeOutput::Path(p.clone())).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Points(v)) => Ok(v.get(1).map(|p| NodeOutput::Point(*p)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Floats(v)) => Ok(v.get(1).map(|f| NodeOutput::Float(*f)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Ints(v)) => Ok(v.get(1).map(|i| NodeOutput::Int(*i)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Strings(v)) => Ok(v.get(1).map(|s| NodeOutput::String(s.clone())).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Booleans(v)) => Ok(v.get(1).map(|b| NodeOutput::Boolean(*b)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Colors(v)) => Ok(v.get(1).map(|c| NodeOutput::Color(*c)).unwrap_or(NodeOutput::None)), + _ => Ok(NodeOutput::None), + } + } + + "list.last" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(v.last().map(|p| NodeOutput::Path(p.clone())).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Points(v)) => Ok(v.last().map(|p| NodeOutput::Point(*p)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Floats(v)) => Ok(v.last().map(|f| NodeOutput::Float(*f)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Ints(v)) => Ok(v.last().map(|i| NodeOutput::Int(*i)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Strings(v)) => Ok(v.last().map(|s| NodeOutput::String(s.clone())).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Booleans(v)) => Ok(v.last().map(|b| NodeOutput::Boolean(*b)).unwrap_or(NodeOutput::None)), + Some(NodeOutput::Colors(v)) => Ok(v.last().map(|c| NodeOutput::Color(*c)).unwrap_or(NodeOutput::None)), + _ => Ok(NodeOutput::None), + } + } + + "list.rest" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::rest(v))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::rest(v))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::rest(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::rest(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::rest(v))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::rest(v))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::rest(v))), + _ => Ok(NodeOutput::None), + } + } + + "list.reverse" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::reverse(v))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::reverse(v))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::reverse(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::reverse(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::reverse(v))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::reverse(v))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::reverse(v))), + _ => Ok(NodeOutput::None), + } + } + + "list.slice" => { + let start_index = get_int(inputs, "start_index", 0) as usize; + let size = get_int(inputs, "size", 10) as usize; + let invert = get_bool(inputs, "invert", false); + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::slice(v, start_index, size, invert))), + _ => Ok(NodeOutput::None), + } + } + + "list.shift" => { + let amount = get_int(inputs, "amount", 1); + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::shift(v, amount))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::shift(v, amount))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::shift(v, amount))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::shift(v, amount))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::shift(v, amount))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::shift(v, amount))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::shift(v, amount))), + _ => Ok(NodeOutput::None), + } + } + + "list.repeat" => { + let amount = get_int(inputs, "amount", 1) as usize; + let per_item = get_bool(inputs, "per_item", false); + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::repeat(v, amount, per_item))), + _ => Ok(NodeOutput::None), + } + } + + "list.sort" => { + match inputs.get("list") { + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::sort_floats(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::sort(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::sort(v))), + Some(other) => Ok(other.clone()), // Non-sortable types pass through + _ => Ok(NodeOutput::None), + } + } + + "list.shuffle" => { + let seed = get_int(inputs, "seed", 0) as u64; + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::shuffle(v, seed))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::shuffle(v, seed))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::shuffle(v, seed))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::shuffle(v, seed))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::shuffle(v, seed))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::shuffle(v, seed))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::shuffle(v, seed))), + _ => Ok(NodeOutput::None), + } + } + + "list.pick" => { + let amount = get_int(inputs, "amount", 5) as usize; + let seed = get_int(inputs, "seed", 0) as u64; + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::pick(v, amount, seed))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::pick(v, amount, seed))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::pick(v, amount, seed))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::pick(v, amount, seed))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::pick(v, amount, seed))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::pick(v, amount, seed))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::pick(v, amount, seed))), + _ => Ok(NodeOutput::None), + } + } + + "list.cull" => { + let booleans = get_booleans(inputs, "booleans"); + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::cull(v, &booleans))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::cull(v, &booleans))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::cull(v, &booleans))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::cull(v, &booleans))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::cull(v, &booleans))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::cull(v, &booleans))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::cull(v, &booleans))), + _ => Ok(NodeOutput::None), + } + } + + "list.take_every" => { + let n = get_int(inputs, "n", 1) as usize; + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(ops::list::take_every(v, n))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(ops::list::take_every(v, n))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::take_every(v, n))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::take_every(v, n))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::take_every(v, n))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::take_every(v, n))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(ops::list::take_every(v, n))), + _ => Ok(NodeOutput::None), + } + } + + "list.distinct" => { + match inputs.get("list") { + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(ops::list::distinct_floats(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(ops::list::distinct(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(ops::list::distinct(v))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(ops::list::distinct(v))), + Some(other) => Ok(other.clone()), // Types without Hash pass through + _ => Ok(NodeOutput::None), + } + } + + "list.switch" => { + let index = get_int(inputs, "index", 0) as usize; + // Collect all input lists and select based on index + let mut lists: Vec<&NodeOutput> = Vec::new(); + for port_name in ["input1", "input2", "input3", "input4", "input5", "input6"] { + if let Some(output) = inputs.get(port_name) { + lists.push(output); + } + } + if lists.is_empty() { + Ok(NodeOutput::None) + } else { + let idx = index % lists.len(); + Ok(lists[idx].clone()) + } + } + + "list.keys" => { + let rows = get_data_rows(inputs, "maps"); + let mut key_set = std::collections::BTreeSet::new(); + for row in &rows { + for key in row.keys() { + key_set.insert(key.clone()); + } + } + let keys: Vec = key_set.into_iter().collect(); + Ok(NodeOutput::Strings(keys)) + } + + "list.zip_map" => { + let keys: Vec = match inputs.get("keys") { + Some(NodeOutput::Strings(ss)) => ss.clone(), + Some(NodeOutput::String(s)) => vec![s.clone()], + _ => Vec::new(), + }; + let values = get_as_data_values(inputs, "values"); + let mut map = HashMap::new(); + for (k, v) in keys.into_iter().zip(values.into_iter()) { + map.insert(k, v); + } + Ok(NodeOutput::DataRow(map)) + } + + // ======================== + // Color nodes (4) + // ======================== + + "color.color" => { + Ok(NodeOutput::Color(get_color(inputs, "color", Color::BLACK))) + } + "color.gray_color" => { + let gray = get_float(inputs, "gray", 0.0); + let alpha = get_float(inputs, "alpha", 255.0); + let range = get_float(inputs, "range", 255.0); + if range == 0.0 { + Ok(NodeOutput::Color(Color::BLACK)) + } else { + Ok(NodeOutput::Color(Color::gray_alpha(gray / range, alpha / range))) + } + } + "color.rgb_color" => { + let r = get_float(inputs, "red", 0.0); + let g = get_float(inputs, "green", 0.0); + let b = get_float(inputs, "blue", 0.0); + let a = get_float(inputs, "alpha", 255.0); + let range = get_float(inputs, "range", 255.0); + if range == 0.0 { + Ok(NodeOutput::Color(Color::BLACK)) + } else { + Ok(NodeOutput::Color(Color::rgba(r / range, g / range, b / range, a / range))) + } + } + "color.hsb_color" => { + let h = get_float(inputs, "hue", 0.0); + let s = get_float(inputs, "saturation", 0.0); + let b = get_float(inputs, "brightness", 0.0); + let a = get_float(inputs, "alpha", 255.0); + let range = get_float(inputs, "range", 255.0); + if range == 0.0 { + Ok(NodeOutput::Color(Color::BLACK)) + } else { + Ok(NodeOutput::Color(Color::hsba(h / range, s / range, b / range, a / range))) + } + } + + // ======================== + // Core nodes + // ======================== + + "core.frame" => { + Ok(NodeOutput::Float(project_context.frame as f64)) + } + + // ======================== + // Network nodes + // ======================== + + "network.http_get" => { + let url = get_string(inputs, "url", ""); + if url.is_empty() { + return Ok(NodeOutput::String(String::new())); + } + match port.http_get(&url) { + Ok(bytes) => Ok(NodeOutput::String(std::string::String::from_utf8_lossy(&bytes).to_string())), + Err(e) => { + log::warn!("HTTP GET error for {}: {}", url, e); + Ok(NodeOutput::String(String::new())) + } + } + } + "network.encode_url" => { + let value = get_string(inputs, "value", ""); + // Simple percent-encoding for common special characters + let encoded = value + .replace('%', "%25") + .replace(' ', "%20") + .replace('&', "%26") + .replace('=', "%3D") + .replace('+', "%2B") + .replace('#', "%23") + .replace('?', "%3F") + .replace('/', "%2F") + .replace('@', "%40") + .replace('!', "%21") + .replace('$', "%24") + .replace('\'', "%27") + .replace('(', "%28") + .replace(')', "%29") + .replace('*', "%2A") + .replace(',', "%2C") + .replace(';', "%3B"); + Ok(NodeOutput::String(encoded)) + } + + // ======================== + // Data nodes + // ======================== + + "data.import_text" => { + let file_path = get_string(inputs, "file", ""); + if file_path.is_empty() { + return Ok(NodeOutput::Strings(Vec::new())); + } + match port.read_text_file(project_context, &file_path) { + Ok(content) => { + let lines: Vec = content.lines().map(|l| l.to_string()).collect(); + Ok(NodeOutput::Strings(lines)) + } + Err(e) => { + log::warn!("Import text error: {}", e); + Ok(NodeOutput::Strings(Vec::new())) + } + } + } + "data.import_csv" => { + let file_path = get_string(inputs, "file", ""); + if file_path.is_empty() { + return Ok(NodeOutput::DataRows(Vec::new())); + } + match port.read_text_file(project_context, &file_path) { + Ok(content) => { + let delimiter = match get_string(inputs, "delimiter", "comma").as_str() { + "semicolon" => b';', + "colon" => b':', + "tab" => b'\t', + "space" => b' ', + _ => b',', + }; + let quote_char = match get_string(inputs, "quotes", "double").as_str() { + "single" => b'\'', + _ => b'"', + }; + let number_separator = get_string(inputs, "number_separator", "period"); + let rows = ops::data::import_csv( + &content, delimiter, quote_char, &number_separator, + ); + Ok(NodeOutput::DataRows(rows)) + } + Err(e) => { + log::warn!("Import CSV error: {}", e); + Ok(NodeOutput::DataRows(Vec::new())) + } + } + } + "data.make_table" => { + let headers_str = get_string(inputs, "headers", "alpha;beta"); + let headers: Vec = headers_str + .split(|c| c == ';' || c == ',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let lists: Vec> = (1..=6) + .map(|i| get_as_data_values(inputs, &format!("list{}", i))) + .collect(); + + let rows = ops::data::make_table(&headers, &lists); + Ok(NodeOutput::DataRows(rows)) + } + "data.lookup" => { + let key = get_string(inputs, "key", "x"); + match inputs.get("list") { + Some(NodeOutput::DataRow(row)) => { + match ops::data::lookup(row, &key) { + Some(DataValue::Float(f)) => Ok(NodeOutput::Float(f)), + Some(DataValue::String(s)) => Ok(NodeOutput::String(s)), + None => Ok(NodeOutput::String(String::new())), + } + } + Some(NodeOutput::DataRows(rows)) if !rows.is_empty() => { + match ops::data::lookup(&rows[0], &key) { + Some(DataValue::Float(f)) => Ok(NodeOutput::Float(f)), + Some(DataValue::String(s)) => Ok(NodeOutput::String(s)), + None => Ok(NodeOutput::String(String::new())), + } + } + _ => Ok(NodeOutput::String(String::new())), + } + } + "data.filter_data" => { + let rows = get_data_rows(inputs, "data"); + let key = get_string(inputs, "key", "name"); + let op = get_string(inputs, "op", "="); + let value = get_string(inputs, "value", ""); + let filtered = ops::data::filter_data(&rows, &key, &op, &value); + Ok(NodeOutput::DataRows(filtered)) + } + + "network.query_json" => { + // Basic JSON path query - simplified implementation + log::warn!("JSON query node not yet fully supported: {}", proto); + Ok(NodeOutput::Strings(Vec::new())) + } + + // Default: pass-through or unknown node + _ => { + // For unknown nodes, try to pass through a shape input + if let Some(path) = get_path(inputs, "shape") { + Ok(NodeOutput::Path(path)) + } else if let Some(path) = get_path(inputs, "shapes") { + Ok(NodeOutput::Path(path)) + } else { + log::warn!("Unknown node prototype: {}", proto); + Ok(NodeOutput::None) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nodebox_core::node::{Port, Connection, PortRange}; + use nodebox_core::platform::{TestPlatform, ProjectContext}; + + /// Create a test platform and project context for evaluation tests. + fn test_platform_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) + } + + #[test] + fn test_evaluate_simple_ellipse() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(100.0, 100.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_rendered_child("ellipse1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + assert!((bounds.width - 50.0).abs() < 0.1); + assert!((bounds.height - 50.0).abs() < 0.1); + } + + #[test] + fn test_evaluate_colorized_ellipse() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(100.0, 100.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))) + .with_input(Port::color("stroke", Color::BLACK)) + .with_input(Port::float("strokeWidth", 2.0)) + ) + .with_connection(Connection::new("ellipse1", "colorize1", "shape")) + .with_rendered_child("colorize1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + // Check that the colorize was applied + assert!(paths[0].fill.is_some()); + let fill = paths[0].fill.unwrap(); + assert!((fill.r - 1.0).abs() < 0.01); + assert!(fill.g < 0.01); + assert!(fill.b < 0.01); + } + + #[test] + fn test_evaluate_merged_shapes() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::new(100.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_child( + Node::new("merge1") + .with_prototype("corevector.merge") + .with_input(Port::geometry("shapes")) + ) + .with_connection(Connection::new("ellipse1", "merge1", "shapes")) + .with_connection(Connection::new("rect1", "merge1", "shapes")) + .with_rendered_child("merge1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + // Merge collects all connected shapes + assert_eq!(paths.len(), 2); + } + + #[test] + fn test_evaluate_rect() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 80.0)) + .with_input(Port::float("height", 40.0)) + ) + .with_rendered_child("rect1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + assert!((bounds.width - 80.0).abs() < 0.1); + assert!((bounds.height - 40.0).abs() < 0.1); + } + + #[test] + fn test_evaluate_line() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("line1") + .with_prototype("corevector.line") + .with_input(Port::point("point1", Point::new(0.0, 0.0))) + .with_input(Port::point("point2", Point::new(100.0, 50.0))) + .with_input(Port::int("points", 2)) + ) + .with_rendered_child("line1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + assert!((bounds.width - 100.0).abs() < 0.1); + assert!((bounds.height - 50.0).abs() < 0.1); + } + + #[test] + fn test_evaluate_polygon() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("polygon1") + .with_prototype("corevector.polygon") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("radius", 50.0)) + .with_input(Port::int("sides", 6)) + .with_input(Port::boolean("align", true)) + ) + .with_rendered_child("polygon1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + // Hexagon with radius 50 should have bounds approximately 100x86 (2*r x sqrt(3)*r) + let bounds = paths[0].bounds().unwrap(); + assert!(bounds.width > 80.0 && bounds.width < 110.0); + assert!(bounds.height > 80.0 && bounds.height < 110.0); + } + + #[test] + fn test_evaluate_star() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("star1") + .with_prototype("corevector.star") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::int("points", 5)) + .with_input(Port::float("outer", 50.0)) + .with_input(Port::float("inner", 25.0)) + ) + .with_rendered_child("star1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + // Star with outer radius 50 should have bounds approximately 100x100 + let bounds = paths[0].bounds().unwrap(); + assert!(bounds.width > 80.0 && bounds.width < 110.0); + } + + #[test] + fn test_evaluate_arc() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("arc1") + .with_prototype("corevector.arc") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::float("start_angle", 0.0)) + .with_input(Port::float("degrees", 180.0)) + .with_input(Port::string("type", "pie")) + ) + .with_rendered_child("arc1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + } + + #[test] + fn test_evaluate_translate() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_child( + Node::new("translate1") + .with_prototype("corevector.translate") + .with_input(Port::geometry("shape")) + .with_input(Port::point("translate", Point::new(100.0, 50.0))) + ) + .with_connection(Connection::new("ellipse1", "translate1", "shape")) + .with_rendered_child("translate1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + // Original ellipse centered at (0,0) translated by (100, 50) + // Center should now be at (100, 50) + let center_x = bounds.x + bounds.width / 2.0; + let center_y = bounds.y + bounds.height / 2.0; + assert!((center_x - 100.0).abs() < 1.0); + assert!((center_y - 50.0).abs() < 1.0); + } + + #[test] + fn test_evaluate_scale() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + ) + .with_child( + Node::new("scale1") + .with_prototype("corevector.scale") + .with_input(Port::geometry("shape")) + .with_input(Port::point("scale", Point::new(50.0, 200.0))) // 50% x, 200% y + .with_input(Port::point("origin", Point::ZERO)) + ) + .with_connection(Connection::new("ellipse1", "scale1", "shape")) + .with_rendered_child("scale1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + // Width should be 50, height should be 200 + assert!((bounds.width - 50.0).abs() < 1.0); + assert!((bounds.height - 200.0).abs() < 1.0); + } + + #[test] + fn test_evaluate_copy() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_child( + Node::new("copy1") + .with_prototype("corevector.copy") + .with_input(Port::geometry("shape")) + .with_input(Port::int("copies", 3)) + .with_input(Port::string("order", "tsr")) + .with_input(Port::point("translate", Point::new(60.0, 0.0))) + .with_input(Port::float("rotate", 0.0)) + .with_input(Port::point("scale", Point::new(100.0, 100.0))) + ) + .with_connection(Connection::new("ellipse1", "copy1", "shape")) + .with_rendered_child("copy1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + // Should have 3 copies + assert_eq!(paths.len(), 3); + } + + #[test] + fn test_evaluate_empty_network() { + let library = NodeLibrary::new("test"); + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert!(paths.is_empty()); + } + + #[test] + fn test_evaluate_no_rendered_child() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ); + // No rendered_child set + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert!(paths.is_empty()); + } + + #[test] + fn test_evaluate_colorize_without_input() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))) + .with_input(Port::color("stroke", Color::BLACK)) + .with_input(Port::float("strokeWidth", 2.0)) + ) + .with_rendered_child("colorize1"); + + // Should handle missing input gracefully + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert!(paths.is_empty()); + } + + #[test] + fn test_evaluate_unknown_node_type() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("unknown1") + .with_prototype("corevector.nonexistent") + ) + .with_rendered_child("unknown1"); + + // Should handle unknown node type gracefully + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert!(paths.is_empty()); + } + + #[test] + fn test_evaluate_resample() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + ) + .with_child( + Node::new("resample1") + .with_prototype("corevector.resample") + .with_input(Port::geometry("shape")) + .with_input(Port::int("points", 20)) + ) + .with_connection(Connection::new("ellipse1", "resample1", "shape")) + .with_rendered_child("resample1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + // Resampled path should have the specified number of points + // Note: exact point count depends on implementation + } + + #[test] + fn test_evaluate_grid() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("grid1") + .with_prototype("corevector.grid") + .with_input(Port::int("columns", 3)) + .with_input(Port::int("rows", 3)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::point("position", Point::ZERO)) + ) + .with_child( + Node::new("connect1") + .with_prototype("corevector.connect") + // points port expects entire list, not individual values + .with_input(Port::geometry("points").with_port_range(PortRange::List)) + .with_input(Port::boolean("closed", false)) + ) + .with_connection(Connection::new("grid1", "connect1", "points")) + .with_rendered_child("connect1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + } + + // ========================================================================= + // Tests for correct port names (matching corevector.ndbx library) + // These tests verify that nodes use "position" (Point) instead of x/y + // ========================================================================= + + #[test] + fn test_ellipse_with_position_port() { + // According to corevector.ndbx, ellipse should use "position" (Point), not x/y + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(100.0, 50.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_rendered_child("ellipse1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + // Ellipse centered at (100, 50) with width/height 50 + // Bounds should be approximately (75, 25) to (125, 75) + let center_x = bounds.x + bounds.width / 2.0; + let center_y = bounds.y + bounds.height / 2.0; + assert!((center_x - 100.0).abs() < 1.0, "Center X should be 100, got {}", center_x); + assert!((center_y - 50.0).abs() < 1.0, "Center Y should be 50, got {}", center_y); + } + + #[test] + fn test_rect_with_position_port() { + // According to corevector.ndbx, rect should use "position" (Point), not x/y + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::new(-50.0, 25.0))) + .with_input(Port::float("width", 80.0)) + .with_input(Port::float("height", 40.0)) + ) + .with_rendered_child("rect1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + let center_x = bounds.x + bounds.width / 2.0; + let center_y = bounds.y + bounds.height / 2.0; + assert!((center_x - (-50.0)).abs() < 1.0, "Center X should be -50, got {}", center_x); + assert!((center_y - 25.0).abs() < 1.0, "Center Y should be 25, got {}", center_y); + } + + #[test] + fn test_rect_with_roundness_port() { + // According to corevector.ndbx, rect should use "roundness" (Point), not rx/ry + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::new(0.0, 0.0))) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::point("roundness", Point::new(10.0, 10.0))) + ) + .with_rendered_child("rect1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + // If roundness is applied, the path should have more points than a simple rect + } + + #[test] + fn test_polygon_with_position_port() { + // According to corevector.ndbx, polygon should use "position" (Point), not x/y + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("polygon1") + .with_prototype("corevector.polygon") + .with_input(Port::point("position", Point::new(200.0, -100.0))) + .with_input(Port::float("radius", 50.0)) + .with_input(Port::int("sides", 6)) + .with_input(Port::boolean("align", true)) + ) + .with_rendered_child("polygon1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + let center_x = bounds.x + bounds.width / 2.0; + let center_y = bounds.y + bounds.height / 2.0; + assert!((center_x - 200.0).abs() < 1.0, "Center X should be 200, got {}", center_x); + assert!((center_y - (-100.0)).abs() < 1.0, "Center Y should be -100, got {}", center_y); + } + + #[test] + fn test_star_with_position_port() { + // According to corevector.ndbx, star should use "position" (Point), not x/y + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("star1") + .with_prototype("corevector.star") + .with_input(Port::point("position", Point::new(75.0, 75.0))) + .with_input(Port::int("points", 5)) + .with_input(Port::float("outer", 50.0)) + .with_input(Port::float("inner", 25.0)) + ) + .with_rendered_child("star1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + let center_x = bounds.x + bounds.width / 2.0; + let center_y = bounds.y + bounds.height / 2.0; + // Star geometry may not be perfectly symmetric, allow some tolerance + assert!((center_x - 75.0).abs() < 10.0, "Center X should be near 75, got {}", center_x); + assert!((center_y - 75.0).abs() < 10.0, "Center Y should be near 75, got {}", center_y); + } + + #[test] + fn test_arc_with_position_and_start_angle() { + // According to corevector.ndbx, arc uses "position" and "start_angle" (underscore) + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("arc1") + .with_prototype("corevector.arc") + .with_input(Port::point("position", Point::new(50.0, -50.0))) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::float("start_angle", 0.0)) + .with_input(Port::float("degrees", 180.0)) + .with_input(Port::string("type", "pie")) + ) + .with_rendered_child("arc1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + let center_x = bounds.x + bounds.width / 2.0; + // Arc center should be near (50, -50) + assert!((center_x - 50.0).abs() < 10.0, "Center X should be near 50, got {}", center_x); + } + + #[test] + fn test_copy_with_translate_and_scale_points() { + // According to corevector.ndbx, copy uses "translate" (Point) and "scale" (Point) + // not tx/ty and sx/sy + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(0.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + ) + .with_child( + Node::new("copy1") + .with_prototype("corevector.copy") + .with_input(Port::geometry("shape")) + .with_input(Port::int("copies", 3)) + .with_input(Port::string("order", "tsr")) + .with_input(Port::point("translate", Point::new(60.0, 0.0))) + .with_input(Port::float("rotate", 0.0)) + .with_input(Port::point("scale", Point::new(100.0, 100.0))) + ) + .with_connection(Connection::new("ellipse1", "copy1", "shape")) + .with_rendered_child("copy1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 3, "Should have 3 copies"); + + // First copy at x=0, second at x=60, third at x=120 + // Check that copies are actually spread out + let bounds0 = paths[0].bounds().unwrap(); + let bounds2 = paths[2].bounds().unwrap(); + let center0_x = bounds0.x + bounds0.width / 2.0; + let center2_x = bounds2.x + bounds2.width / 2.0; + assert!((center2_x - center0_x - 120.0).abs() < 1.0, + "Third copy should be 120 units from first, got {}", center2_x - center0_x); + } + + #[test] + fn test_grid_with_position_port() { + // According to corevector.ndbx, grid uses "position" (Point), not x/y + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("grid1") + .with_prototype("corevector.grid") + .with_input(Port::int("columns", 3)) + .with_input(Port::int("rows", 3)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::point("position", Point::new(50.0, 50.0))) + ) + .with_child( + Node::new("connect1") + .with_prototype("corevector.connect") + // points port expects entire list, not individual values + .with_input(Port::geometry("points").with_port_range(PortRange::List)) + .with_input(Port::boolean("closed", false)) + ) + .with_connection(Connection::new("grid1", "connect1", "points")) + .with_rendered_child("connect1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + let bounds = paths[0].bounds().unwrap(); + let center_x = bounds.x + bounds.width / 2.0; + let center_y = bounds.y + bounds.height / 2.0; + assert!((center_x - 50.0).abs() < 1.0, "Center X should be 50, got {}", center_x); + assert!((center_y - 50.0).abs() < 1.0, "Center Y should be 50, got {}", center_y); + } + + #[test] + fn test_wiggle_with_offset_point() { + // According to corevector.ndbx, wiggle uses "offset" (Point), not offsetX/offsetY + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(0.0, 0.0))) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + ) + .with_child( + Node::new("wiggle1") + .with_prototype("corevector.wiggle") + .with_input(Port::geometry("shape")) + .with_input(Port::string("scope", "points")) + .with_input(Port::point("offset", Point::new(10.0, 10.0))) + .with_input(Port::int("seed", 42)) + ) + .with_connection(Connection::new("ellipse1", "wiggle1", "shape")) + .with_rendered_child("wiggle1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert!(!paths.is_empty(), "Wiggle should produce output"); + } + + #[test] + fn test_fit_with_position_and_keep_proportions() { + // According to corevector.ndbx, fit uses "position" (Point) and "keep_proportions" + // Test that fit reads from position port (not x/y) and keep_proportions (not keepProportions) + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(0.0, 0.0))) + .with_input(Port::float("width", 200.0)) + .with_input(Port::float("height", 100.0)) + ) + .with_child( + Node::new("fit1") + .with_prototype("corevector.fit") + .with_input(Port::geometry("shape")) + .with_input(Port::point("position", Point::new(100.0, 100.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)) + .with_input(Port::boolean("keep_proportions", true)) + ) + .with_connection(Connection::new("ellipse1", "fit1", "shape")) + .with_rendered_child("fit1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + assert_eq!(paths.len(), 1); + + // Verify fit produced output - the shape should be constrained to max 50x50 + // With keep_proportions=true and input 200x100, output should be 50x25 + let bounds = paths[0].bounds().unwrap(); + assert!(bounds.width <= 51.0, "Width should be at most 50, got {}", bounds.width); + assert!(bounds.height <= 51.0, "Height should be at most 50, got {}", bounds.height); + } + + #[test] + fn test_node_output_conversions() { + // Test to_paths() + let path = Path::new(); + let output = NodeOutput::Path(path.clone()); + assert_eq!(output.to_paths().len(), 1); + + let output = NodeOutput::Paths(vec![path.clone(), path.clone()]); + assert_eq!(output.to_paths().len(), 2); + + let output = NodeOutput::Float(1.0); + assert!(output.to_paths().is_empty()); + + // Test as_path() + let output = NodeOutput::Path(path.clone()); + assert!(output.as_path().is_some()); + + let output = NodeOutput::Float(1.0); + assert!(output.as_path().is_none()); + + // Test as_paths() + let output = NodeOutput::Path(path.clone()); + assert!(output.as_paths().is_some()); + assert_eq!(output.as_paths().unwrap().len(), 1); + + let output = NodeOutput::Paths(vec![path.clone(), path.clone()]); + assert!(output.as_paths().is_some()); + assert_eq!(output.as_paths().unwrap().len(), 2); + + let output = NodeOutput::Float(1.0); + assert!(output.as_paths().is_none()); + + // Test is_point_output() + let output = NodeOutput::Point(Point::ZERO); + assert!(output.is_point_output()); + + let output = NodeOutput::Points(vec![Point::ZERO, Point::new(1.0, 1.0)]); + assert!(output.is_point_output()); + + let output = NodeOutput::Path(path.clone()); + assert!(!output.is_point_output()); + + let output = NodeOutput::Paths(vec![path.clone()]); + assert!(!output.is_point_output()); + + let output = NodeOutput::Float(1.0); + assert!(!output.is_point_output()); + + let output = NodeOutput::None; + assert!(!output.is_point_output()); + } + + #[test] + fn test_list_combine_single_items() { + // Test that list.combine works when each input is a single path + // This mimics the primitives.ndbx structure: colorize1 -> combine.list1, etc. + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::new(-100.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)), + ) + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(0.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)), + ) + .with_child( + Node::new("polygon1") + .with_prototype("corevector.polygon") + .with_input(Port::point("position", Point::new(100.0, 0.0))) + .with_input(Port::float("radius", 25.0)) + .with_input(Port::int("sides", 6)), + ) + .with_child( + Node::new("combine1") + .with_prototype("list.combine") + // Note: list.combine ports should accept lists, not iterate over them + .with_input(Port::geometry("list1").with_port_range(PortRange::List)) + .with_input(Port::geometry("list2").with_port_range(PortRange::List)) + .with_input(Port::geometry("list3").with_port_range(PortRange::List)), + ) + .with_connection(Connection::new("rect1", "combine1", "list1")) + .with_connection(Connection::new("ellipse1", "combine1", "list2")) + .with_connection(Connection::new("polygon1", "combine1", "list3")) + .with_rendered_child("combine1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + + assert_eq!( + paths.len(), + 3, + "list.combine should produce 3 paths (one from each input), got {}", + paths.len() + ); + } + + #[test] + fn test_list_combine_with_colorize_chain() { + // Test the full primitives.ndbx structure: + // rect1 -> colorize1 -> combine1.list1 + // ellipse1 -> colorize2 -> combine1.list2 + // polygon1 -> colorize3 -> combine1.list3 + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::new(-100.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)), + ) + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(0.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)), + ) + .with_child( + Node::new("polygon1") + .with_prototype("corevector.polygon") + .with_input(Port::point("position", Point::new(100.0, 0.0))) + .with_input(Port::float("radius", 25.0)) + .with_input(Port::int("sides", 6)), + ) + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))), + ) + .with_child( + Node::new("colorize2") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(0.0, 1.0, 0.0))), + ) + .with_child( + Node::new("colorize3") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(0.0, 0.0, 1.0))), + ) + .with_child( + Node::new("combine1") + .with_prototype("list.combine") + // NO port definitions - simulates ndbx file + ) + .with_connection(Connection::new("rect1", "colorize1", "shape")) + .with_connection(Connection::new("ellipse1", "colorize2", "shape")) + .with_connection(Connection::new("polygon1", "colorize3", "shape")) + .with_connection(Connection::new("colorize1", "combine1", "list1")) + .with_connection(Connection::new("colorize2", "combine1", "list2")) + .with_connection(Connection::new("colorize3", "combine1", "list3")) + .with_rendered_child("combine1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + + assert_eq!( + paths.len(), + 3, + "combine1 should produce 3 colorized paths, got {}", + paths.len() + ); + + // Verify all paths have fills + for (i, path) in paths.iter().enumerate() { + assert!(path.fill.is_some(), "Path {} should have a fill color", i); + } + } + + #[test] + fn test_colorize_without_shape_port_defined() { + // Test colorize when the shape port is NOT defined (as in ndbx files) + // The ndbx file only defines ports that have non-default values + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)), + ) + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + // Only fill is defined, NOT shape - mimics ndbx file + .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))), + ) + .with_connection(Connection::new("rect1", "colorize1", "shape")) + .with_rendered_child("colorize1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + + assert_eq!( + paths.len(), + 1, + "colorize1 should produce 1 path even without shape port defined, got {}", + paths.len() + ); + assert!(paths[0].fill.is_some(), "Path should have a fill color"); + } + + #[test] + fn test_list_combine_without_port_range() { + // Test what happens when list.combine ports don't have PortRange::List set + // This is the case when loading from ndbx files that don't define ports + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::new(-100.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)), + ) + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(0.0, 0.0))) + .with_input(Port::float("width", 50.0)) + .with_input(Port::float("height", 50.0)), + ) + .with_child( + Node::new("combine1") + .with_prototype("list.combine") + // NO port definitions - simulates ndbx file without explicit ports + ) + .with_connection(Connection::new("rect1", "combine1", "list1")) + .with_connection(Connection::new("ellipse1", "combine1", "list2")) + .with_rendered_child("combine1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + + // With no port definitions, list matching treats inputs as VALUE range + // Each input is a single path, so iteration count = 1 + // list.combine should still combine them + assert_eq!( + paths.len(), + 2, + "list.combine should produce 2 paths even without port definitions, got {}", + paths.len() + ); + } + + #[test] + fn test_grid_to_rect_list_matching() { + // This test reproduces the bug: grid (100 points) -> rect should produce 100 rects + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("grid1") + .with_prototype("corevector.grid") + .with_input(Port::int("columns", 10)) + .with_input(Port::int("rows", 10)) + .with_input(Port::float("width", 300.0)) + .with_input(Port::float("height", 300.0)) + .with_input(Port::point("position", Point::ZERO)), + ) + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 20.0)) + .with_input(Port::float("height", 20.0)) + .with_input(Port::point("roundness", Point::ZERO)), + ) + .with_connection(Connection::new("grid1", "rect1", "position")) + .with_rendered_child("rect1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + + // THE KEY ASSERTION: Must produce 100 rectangles, not 1! + assert_eq!( + paths.len(), + 100, + "Grid (10x10=100 points) -> rect should produce 100 rectangles, got {}", + paths.len() + ); + } + + // ========================================================================= + // Tests for error handling + // ========================================================================= + + #[test] + fn test_missing_input_produces_error() { + // A colorize node without a connected shape input should produce an error + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))) + ) + .with_rendered_child("colorize1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); + + // Should have no paths output + assert!(paths.is_empty(), "Should have no output on missing input, got {} paths", paths.len()); + + // Should have an error about the missing shape input + assert!(!errors.is_empty(), "Should have an error for missing input"); + assert_eq!(errors[0].node_name, "colorize1", "Error should be on colorize1 node"); + assert!( + errors[0].message.contains("shape") || errors[0].message.contains("Port"), + "Error message should mention missing port: {}", + errors[0].message + ); + } + + #[test] + fn test_error_propagates_through_connected_nodes() { + // If colorize1 has no input, translate1 connected to it should also fail + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + ) + .with_child( + Node::new("translate1") + .with_prototype("corevector.translate") + .with_input(Port::geometry("shape")) + .with_input(Port::point("translate", Point::new(10.0, 10.0))) + ) + .with_connection(Connection::new("colorize1", "translate1", "shape")) + .with_rendered_child("translate1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); + + // Should have no output + assert!(paths.is_empty(), "Should have no output when upstream has error"); + + // Should have an error (from colorize1, propagated) + assert!(!errors.is_empty(), "Should have an error propagated from upstream"); + } + + #[test] + fn test_successful_evaluation_returns_no_errors() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + ) + .with_rendered_child("ellipse1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); + + // Should have output + assert!(!paths.is_empty(), "Should have output for valid network"); + + // Should have no errors + assert!(errors.is_empty(), "Should have no errors for valid network"); + } + + #[test] + fn test_error_message_includes_node_name() { + // Error messages should include the node name for easy identification + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("my_colorize_node") + .with_prototype("corevector.colorize") + .with_input(Port::geometry("shape")) + ) + .with_rendered_child("my_colorize_node"); + + let (port, ctx) = test_platform_and_context(); + let (_paths, _output, errors) = evaluate_network(&library, &port, &ctx); + + assert!(!errors.is_empty(), "Should have an error"); + assert_eq!( + errors[0].node_name, "my_colorize_node", + "Error should identify the failing node" + ); + } + + #[test] + fn test_sample_to_rgb_color_list_matching() { + // When a sample node (outputting a list of floats) is connected to the "red" port + // of an rgb_color node, we expect a list of Colors to be returned, not None. + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("sample1") + .with_prototype("math.sample") + .with_input(Port::int("amount", 5)) + .with_input(Port::float("start", 0.0)) + .with_input(Port::float("end", 255.0)), + ) + .with_child( + Node::new("rgb1") + .with_prototype("color.rgb_color") + .with_input(Port::float("red", 0.0)) + .with_input(Port::float("green", 0.0)) + .with_input(Port::float("blue", 0.0)) + .with_input(Port::float("alpha", 255.0)) + .with_input(Port::float("range", 255.0)), + ) + .with_connection(Connection::new("sample1", "rgb1", "red")) + .with_rendered_child("rgb1"); + + let (port, ctx) = test_platform_and_context(); + let (_paths, output, errors) = evaluate_network(&library, &port, &ctx); + + assert!(errors.is_empty(), "Should not produce errors: {:?}", errors); + // sample produces 5 floats -> rgb_color should produce 5 colors + match &output { + NodeOutput::Colors(colors) => { + assert_eq!(colors.len(), 5, "Expected 5 colors, got {}", colors.len()); + // First color: red=0/255=0.0 + assert!(colors[0].r.abs() < 0.01, "First color red should be ~0.0"); + // Last color: red=255/255=1.0 + assert!((colors[4].r - 1.0).abs() < 0.01, "Last color red should be ~1.0"); + } + other => panic!("Expected Colors output, got {:?}", other), + } + } + + #[test] + fn test_generator_nodes_never_error() { + // Generator nodes (ellipse, rect, etc.) should never produce errors + // as they have defaults for all inputs + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + // No inputs specified - should use defaults + ) + .with_rendered_child("ellipse1"); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); + + assert!(!paths.is_empty(), "Generator should produce output with defaults"); + assert!(errors.is_empty(), "Generator should not produce errors"); + } + + #[test] + fn test_collect_results_colors() { + let results = vec![ + NodeOutput::Color(Color::rgb(1.0, 0.0, 0.0)), + NodeOutput::Color(Color::rgb(0.0, 1.0, 0.0)), + NodeOutput::Color(Color::rgb(0.0, 0.0, 1.0)), + ]; + let collected = collect_results(results); + match &collected { + NodeOutput::Colors(cs) => { + assert_eq!(cs.len(), 3); + assert!((cs[0].r - 1.0).abs() < 0.01); + assert!((cs[1].g - 1.0).abs() < 0.01); + assert!((cs[2].b - 1.0).abs() < 0.01); + } + other => panic!("Expected Colors, got {:?}", other), + } + } + + #[test] + fn test_sample_to_rgb_color_produces_colors() { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("sample1") + .with_prototype("math.sample") + .with_input(Port::int("amount", 3)) + .with_input(Port::float("start", 0.0)) + .with_input(Port::float("end", 255.0)) + .with_output_range(PortRange::List) + ) + .with_child( + Node::new("rgb_color1") + .with_prototype("color.rgb_color") + .with_input(Port::float("red", 0.0)) + .with_input(Port::float("green", 0.0)) + .with_input(Port::float("blue", 0.0)) + .with_input(Port::float("alpha", 255.0)) + .with_input(Port::float("range", 255.0)) + .with_output_type(nodebox_core::node::PortType::Color) + ) + .with_connection(Connection::new("sample1", "rgb_color1", "red")) + .with_rendered_child("rgb_color1"); + + let (port, ctx) = test_platform_and_context(); + let (_paths, output, errors) = evaluate_network(&library, &port, &ctx); + assert!(errors.is_empty(), "Should not produce errors: {:?}", errors); + match &output { + NodeOutput::Colors(cs) => { + assert_eq!(cs.len(), 3, "Should produce 3 colors"); + } + other => panic!("Expected Colors, got {:?}", other), + } + } + + #[test] + fn test_colors_item_count() { + let output = NodeOutput::Colors(vec![ + Color::rgb(1.0, 0.0, 0.0), + Color::rgb(0.0, 1.0, 0.0), + ]); + assert_eq!(output.item_count(), 2); + } + + #[test] + fn test_colors_is_color() { + let output = NodeOutput::Colors(vec![Color::rgb(1.0, 0.0, 0.0)]); + assert!(output.is_color()); + } + + #[test] + fn test_colors_color_at() { + let output = NodeOutput::Colors(vec![ + Color::rgb(1.0, 0.0, 0.0), + Color::rgb(0.0, 1.0, 0.0), + Color::rgb(0.0, 0.0, 1.0), + ]); + let c0 = output.color_at(0).unwrap(); + assert!((c0.r - 1.0).abs() < 0.01); + let c1 = output.color_at(1).unwrap(); + assert!((c1.g - 1.0).abs() < 0.01); + let c2 = output.color_at(2).unwrap(); + assert!((c2.b - 1.0).abs() < 0.01); + assert!(output.color_at(3).is_none()); + } +} diff --git a/crates/nodebox-gui/src/export.rs b/crates/nodebox-desktop/src/export.rs similarity index 90% rename from crates/nodebox-gui/src/export.rs rename to crates/nodebox-desktop/src/export.rs index 23d8452e..7db19884 100644 --- a/crates/nodebox-gui/src/export.rs +++ b/crates/nodebox-desktop/src/export.rs @@ -92,6 +92,30 @@ fn draw_path_with_transform(pixmap: &mut Pixmap, geo_path: &GeoPath, transform: // Skip curve data points, they're handled with CurveTo i += 1; } + PointType::QuadTo => { + // Quadratic bezier: current point is control point + if i + 1 < contour.points.len() { + let ctrl = &contour.points[i]; + let end = &contour.points[i + 1]; + + if first { + builder.move_to(ctrl.point.x as f32, ctrl.point.y as f32); + first = false; + } + + builder.quad_to( + ctrl.point.x as f32, ctrl.point.y as f32, + end.point.x as f32, end.point.y as f32, + ); + i += 2; + } else { + i += 1; + } + } + PointType::QuadData => { + // Skip quad data points, they're handled with QuadTo + i += 1; + } } } diff --git a/crates/nodebox-gui/src/handles.rs b/crates/nodebox-desktop/src/handles.rs similarity index 100% rename from crates/nodebox-gui/src/handles.rs rename to crates/nodebox-desktop/src/handles.rs diff --git a/crates/nodebox-desktop/src/history.rs b/crates/nodebox-desktop/src/history.rs new file mode 100644 index 00000000..859e00e2 --- /dev/null +++ b/crates/nodebox-desktop/src/history.rs @@ -0,0 +1,174 @@ +//! Undo/redo history management. + +use std::collections::HashSet; +use std::sync::Arc; +use nodebox_core::node::NodeLibrary; + +/// Maximum number of undo states to keep. +const MAX_HISTORY: usize = 50; + +/// A snapshot of the selection state at a point in time. +#[derive(Clone, Debug, Default)] +pub struct SelectionSnapshot { + /// Names of selected nodes in the network view. + pub selected_nodes: HashSet, + /// The single "active" selected node (shown in parameter panel). + pub selected_node: Option, +} + +/// The undo/redo history manager. +pub struct History { + /// Past states (undo stack). + undo_stack: Vec<(Arc, SelectionSnapshot)>, + /// Future states (redo stack). + redo_stack: Vec<(Arc, SelectionSnapshot)>, + /// The last saved state (to track changes). + #[allow(dead_code)] + last_saved_state: Option>, + /// When set, an undo group is active: `save_state` calls are suppressed + /// and the stored state will be pushed as a single undo entry on `end_undo_group`. + group_start_state: Option<(Arc, SelectionSnapshot)>, +} + +impl Default for History { + fn default() -> Self { + Self::new() + } +} + +impl History { + /// Create a new empty history. + pub fn new() -> Self { + Self { + undo_stack: Vec::new(), + redo_stack: Vec::new(), + last_saved_state: None, + group_start_state: None, + } + } + + /// Check if undo is available. + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Check if redo is available. + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + /// Save the current state before making changes. + /// Call this BEFORE modifying the library. + /// + /// If an undo group is active (between `begin_undo_group` and `end_undo_group`), + /// this call is suppressed — the group will create a single undo entry on end. + pub fn save_state(&mut self, library: &Arc, selection: &SelectionSnapshot) { + if self.group_start_state.is_some() { + return; + } + + self.undo_stack.push((Arc::clone(library), selection.clone())); + + // Clear redo stack when new changes are made + self.redo_stack.clear(); + + // Limit history size + while self.undo_stack.len() > MAX_HISTORY { + self.undo_stack.remove(0); + } + } + + /// Begin an undo group. All `save_state` calls between `begin_undo_group` and + /// `end_undo_group` are suppressed. When the group ends, a single undo entry + /// is created that restores the state from before the group started. + /// + /// If a group is already active, this call is ignored (the first begin wins). + pub fn begin_undo_group(&mut self, library: &Arc, selection: &SelectionSnapshot) { + if self.group_start_state.is_none() { + self.group_start_state = Some((Arc::clone(library), selection.clone())); + } + } + + /// End an undo group. Pushes the pre-group state as a single undo entry. + /// If no group is active, this is a no-op. If the state hasn't changed + /// since the group started, no undo entry is created. + pub fn end_undo_group(&mut self, current: &Arc) { + if let Some((start_lib, start_sel)) = self.group_start_state.take() { + if start_lib.as_ref() != current.as_ref() { + self.undo_stack.push((start_lib, start_sel)); + self.redo_stack.clear(); + while self.undo_stack.len() > MAX_HISTORY { + self.undo_stack.remove(0); + } + } + } + } + + /// Check if an undo group is currently active. + pub fn is_in_group(&self) -> bool { + self.group_start_state.is_some() + } + + /// Undo the last change, returning the previous state and selection. + /// Call this to restore the library to its previous state. + pub fn undo( + &mut self, + current: &Arc, + current_selection: &SelectionSnapshot, + ) -> Option<(Arc, SelectionSnapshot)> { + if let Some((previous_lib, previous_sel)) = self.undo_stack.pop() { + // Save current state for redo + self.redo_stack.push((Arc::clone(current), current_selection.clone())); + Some((previous_lib, previous_sel)) + } else { + None + } + } + + /// Redo the last undone change, returning the restored state and selection. + pub fn redo( + &mut self, + current: &Arc, + current_selection: &SelectionSnapshot, + ) -> Option<(Arc, SelectionSnapshot)> { + if let Some((next_lib, next_sel)) = self.redo_stack.pop() { + // Save current state for undo + self.undo_stack.push((Arc::clone(current), current_selection.clone())); + Some((next_lib, next_sel)) + } else { + None + } + } + + /// Mark the current state as saved. + #[allow(dead_code)] + pub fn mark_saved(&mut self, library: &Arc) { + self.last_saved_state = Some(Arc::clone(library)); + } + + /// Check if the library has unsaved changes since the last save. + #[allow(dead_code)] + pub fn has_unsaved_changes(&self, library: &NodeLibrary) -> bool { + match &self.last_saved_state { + Some(saved) => saved.as_ref() != library, + None => true, // Never saved, so always has changes + } + } + + /// Clear all history. + #[allow(dead_code)] + pub fn clear(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + } + + /// Get the number of undo states available. + pub fn undo_count(&self) -> usize { + self.undo_stack.len() + } + + /// Get the number of redo states available. + pub fn redo_count(&self) -> usize { + self.redo_stack.len() + } +} diff --git a/crates/nodebox-gui/src/icon_cache.rs b/crates/nodebox-desktop/src/icon_cache.rs similarity index 100% rename from crates/nodebox-gui/src/icon_cache.rs rename to crates/nodebox-desktop/src/icon_cache.rs diff --git a/crates/nodebox-gui/src/lib.rs b/crates/nodebox-desktop/src/lib.rs similarity index 68% rename from crates/nodebox-gui/src/lib.rs rename to crates/nodebox-desktop/src/lib.rs index 945b8fd0..c2cf64f2 100644 --- a/crates/nodebox-gui/src/lib.rs +++ b/crates/nodebox-desktop/src/lib.rs @@ -1,7 +1,7 @@ -//! NodeBox GUI - Native graphical interface for NodeBox +//! NodeBox Desktop - Native graphical interface for NodeBox //! -//! This library provides the core components for creating a visual environment -//! for generative designs using NodeBox's node-based workflow. +//! This library provides the desktop GUI and platform implementation for +//! generative designs using NodeBox's node-based workflow. //! //! # Testing //! @@ -19,6 +19,11 @@ //! - `vello_convert` - Geometry conversion from nodebox-core to Vello types //! - `vello_renderer` - High-level Vello renderer wrapper +#[cfg(not(target_arch = "wasm32"))] +mod desktop_platform; +#[cfg(not(target_arch = "wasm32"))] +pub use desktop_platform::DesktopPlatform; + mod address_bar; mod animation_bar; pub mod app; @@ -32,9 +37,10 @@ mod icon_cache; mod network_view; mod node_library; mod node_selection_dialog; +mod notification_banner; mod pan_zoom; -mod panels; -mod render_worker; +mod parameter_panel; +pub mod render_worker; pub mod state; mod theme; mod timeline; @@ -50,12 +56,13 @@ pub mod vello_viewer; // Re-export key types for testing and external use pub use app::NodeBoxApp; -pub use history::History; -pub use state::{populate_default_ports, AppState}; +pub use history::{History, SelectionSnapshot}; +pub use state::{populate_default_ports, AppState, Notification, NotificationLevel}; // Re-export commonly used types from dependencies pub use nodebox_core::geometry::{Color, Path, Point}; pub use nodebox_core::node::{Connection, Node, NodeLibrary, Port}; +pub use nodebox_core::platform::Platform; pub use nodebox_core::Value; // Re-export GPU rendering types when feature is enabled @@ -67,24 +74,27 @@ pub use vello_renderer::{VelloConfig, VelloError, VelloRenderer, ViewTransform}; pub use vello_viewer::VelloViewer; mod native_menu; +mod recent_files; -use native_menu::NativeMenuHandle; use std::path::PathBuf; +use std::sync::Arc; /// Run the NodeBox GUI application. +/// +/// This is a convenience function that creates a DesktopPlatform and runs the app. +/// For more control, use `NodeBoxApp::new_with_port` directly. pub fn run() -> eframe::Result<()> { // Initialize logging env_logger::init(); - // Initialize native menu bar (macOS) - // Must be done before eframe starts, and menu handle is passed to the app - let native_menu = NativeMenuHandle::new(); + // Create the desktop platform for file operations + let port: Arc = Arc::new(crate::DesktopPlatform::new()); // Get initial file from command line arguments let initial_file: Option = std::env::args() .nth(1) .map(PathBuf::from) - .filter(|p| p.extension().map_or(false, |ext| ext == "ndbx")); + .filter(|p| p.extension().is_some_and(|ext| ext == "ndbx")); // Native options let options = eframe::NativeOptions { @@ -99,6 +109,6 @@ pub fn run() -> eframe::Result<()> { eframe::run_native( "NodeBox", options, - Box::new(move |cc| Ok(Box::new(NodeBoxApp::new_with_file(cc, initial_file, Some(native_menu))))), + Box::new(move |cc| Ok(Box::new(NodeBoxApp::new_with_port(cc, port.clone(), initial_file)))), ) } diff --git a/crates/nodebox-gui/src/native_menu.rs b/crates/nodebox-desktop/src/native_menu.rs similarity index 69% rename from crates/nodebox-gui/src/native_menu.rs rename to crates/nodebox-desktop/src/native_menu.rs index a12c2001..5659462b 100644 --- a/crates/nodebox-gui/src/native_menu.rs +++ b/crates/nodebox-desktop/src/native_menu.rs @@ -5,15 +5,18 @@ #![allow(dead_code)] #[cfg(target_os = "macos")] -use muda::{Menu, MenuItem, PredefinedMenuItem, Submenu, accelerator::Accelerator, MenuEvent}; +use muda::{Menu, MenuId, MenuItem, PredefinedMenuItem, Submenu, accelerator::Accelerator, MenuEvent}; #[cfg(target_os = "macos")] -use std::cell::Cell; +use std::cell::{Cell, RefCell}; +use std::path::PathBuf; /// Menu item identifiers for handling menu events. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum MenuAction { New, Open, + OpenRecent(PathBuf), + ClearRecent, Save, SaveAs, ExportPng, @@ -36,18 +39,22 @@ pub enum MenuAction { pub struct NativeMenuHandle { menu: Menu, initialized: Cell, - new_id: muda::MenuId, - open_id: muda::MenuId, - save_id: muda::MenuId, - save_as_id: muda::MenuId, - export_png_id: muda::MenuId, - export_svg_id: muda::MenuId, - undo_id: muda::MenuId, - redo_id: muda::MenuId, - zoom_in_id: muda::MenuId, - zoom_out_id: muda::MenuId, - zoom_reset_id: muda::MenuId, - about_id: muda::MenuId, + new_id: MenuId, + open_id: MenuId, + recent_submenu: Submenu, + clear_recent_id: MenuId, + /// Map from menu IDs to file paths for recent files + recent_file_ids: RefCell>, + save_id: MenuId, + save_as_id: MenuId, + export_png_id: MenuId, + export_svg_id: MenuId, + undo_id: MenuId, + redo_id: MenuId, + zoom_in_id: MenuId, + zoom_out_id: MenuId, + zoom_reset_id: MenuId, + about_id: MenuId, } #[cfg(not(target_os = "macos"))] @@ -79,6 +86,14 @@ impl NativeMenuHandle { let new_id = new_item.id().clone(); let open_item = MenuItem::new("Open...", true, Some(Accelerator::new(Some(muda::accelerator::Modifiers::META), muda::accelerator::Code::KeyO))); let open_id = open_item.id().clone(); + + // Open Recent submenu + let recent_submenu = Submenu::new("Open Recent", true); + let clear_recent = MenuItem::new("Clear Recent", true, None); + let clear_recent_id = clear_recent.id().clone(); + // Start with just "Clear Recent" (will be rebuilt with files later) + recent_submenu.append(&clear_recent).unwrap(); + let save_item = MenuItem::new("Save", true, Some(Accelerator::new(Some(muda::accelerator::Modifiers::META), muda::accelerator::Code::KeyS))); let save_id = save_item.id().clone(); let save_as_item = MenuItem::new("Save As...", true, Some(Accelerator::new(Some(muda::accelerator::Modifiers::META | muda::accelerator::Modifiers::SHIFT), muda::accelerator::Code::KeyS))); @@ -94,6 +109,7 @@ impl NativeMenuHandle { file_menu.append(&new_item).unwrap(); file_menu.append(&open_item).unwrap(); + file_menu.append(&recent_submenu).unwrap(); file_menu.append(&PredefinedMenuItem::separator()).unwrap(); file_menu.append(&save_item).unwrap(); file_menu.append(&save_as_item).unwrap(); @@ -156,6 +172,9 @@ impl NativeMenuHandle { initialized: Cell::new(false), new_id, open_id, + recent_submenu, + clear_recent_id, + recent_file_ids: RefCell::new(Vec::new()), save_id, save_as_id, export_png_id, @@ -169,6 +188,46 @@ impl NativeMenuHandle { } } + /// Rebuild the "Open Recent" submenu with the given list of files. + pub fn rebuild_recent_menu(&self, files: &[PathBuf]) { + // Clear all items from the submenu by removing each one based on its kind + let items = self.recent_submenu.items(); + for item in items { + match item { + muda::MenuItemKind::MenuItem(m) => { let _ = self.recent_submenu.remove(&m); } + muda::MenuItemKind::Submenu(s) => { let _ = self.recent_submenu.remove(&s); } + muda::MenuItemKind::Predefined(p) => { let _ = self.recent_submenu.remove(&p); } + muda::MenuItemKind::Check(c) => { let _ = self.recent_submenu.remove(&c); } + muda::MenuItemKind::Icon(i) => { let _ = self.recent_submenu.remove(&i); } + } + } + + // Clear the recent file IDs mapping + self.recent_file_ids.borrow_mut().clear(); + + // Add file items + for path in files { + // Use filename for display, full path for tooltip would be nice but muda doesn't support it + let display_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown"); + let item = MenuItem::new(display_name, true, None); + let id = item.id().clone(); + self.recent_submenu.append(&item).unwrap(); + self.recent_file_ids.borrow_mut().push((id, path.clone())); + } + + // Add separator and Clear Recent if there are files + if !files.is_empty() { + self.recent_submenu.append(&PredefinedMenuItem::separator()).unwrap(); + } + + // Add Clear Recent item + let clear_item = MenuItem::new("Clear Recent", !files.is_empty(), None); + self.recent_submenu.append(&clear_item).unwrap(); + } + /// Ensure the menu is initialized for NSApp. /// Must be called after NSApplication exists (i.e., after eframe starts). fn ensure_initialized(&self) { @@ -209,6 +268,24 @@ impl NativeMenuHandle { } else if event.id == self.about_id { return Some(MenuAction::About); } + + // Check recent file IDs + let recent_ids = self.recent_file_ids.borrow(); + for (id, path) in recent_ids.iter() { + if event.id == *id { + return Some(MenuAction::OpenRecent(path.clone())); + } + } + drop(recent_ids); + + // Check for Clear Recent - look through submenu items + for item in self.recent_submenu.items() { + if let muda::MenuItemKind::MenuItem(menu_item) = item { + if menu_item.id() == &event.id && menu_item.text() == "Clear Recent" { + return Some(MenuAction::ClearRecent); + } + } + } } None } @@ -223,6 +300,10 @@ impl NativeMenuHandle { pub fn poll_event(&self) -> Option { None } + + pub fn rebuild_recent_menu(&self, _files: &[PathBuf]) { + // No-op on non-macOS platforms + } } impl Default for NativeMenuHandle { diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-desktop/src/network_view.rs similarity index 68% rename from crates/nodebox-gui/src/network_view.rs rename to crates/nodebox-desktop/src/network_view.rs index 6b868930..0a5d0896 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-desktop/src/network_view.rs @@ -3,7 +3,8 @@ use eframe::egui::{self, Color32, Pos2, Rect, Stroke, Vec2}; use nodebox_core::geometry::Point; use nodebox_core::node::{Connection, Node, NodeLibrary, PortType}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use crate::icon_cache::IconCache; use crate::pan_zoom::PanZoom; @@ -16,6 +17,16 @@ pub enum NetworkAction { None, /// Open the node selection dialog at the given position (in grid units). OpenNodeDialog(Point), + /// Open the node selection dialog filtered by type compatibility, + /// for creating a node and connecting it to an existing output. + OpenNodeDialogForConnection { + /// Position where the new node should be created (in grid units). + position: Point, + /// The source node whose output is being connected. + from_node: String, + /// The output type of the source node (for filtering compatible nodes). + output_type: PortType, + }, } @@ -39,6 +50,16 @@ pub struct NetworkView { hovered_output: Option, /// Cache for node icons. icon_cache: IconCache, + /// Whether we are currently rect-selecting (rubber band selection). + is_rect_selecting: bool, + /// Start point of the rect selection (screen coordinates). + drag_select_start: Pos2, + /// Current point of the rect selection (screen coordinates). + drag_select_current: Pos2, + /// Selection state before the rect selection started (for shift+drag additive selection). + selection_before_drag: HashSet, + /// Whether the current drag started as an alt-drag copy operation. + is_alt_copy_drag: bool, } /// State for dragging a new connection. @@ -49,6 +70,9 @@ struct ConnectionDrag { output_type: PortType, /// Current mouse position (end of wire). to_pos: Pos2, + /// Whether this drag originated from a disconnect-and-reroute (input port drag). + /// When true, releasing on empty space will NOT open the node selection dialog. + is_reroute: bool, } /// Visual constants (matching NodeBox Java). @@ -90,16 +114,102 @@ impl NetworkView { hovered_port: None, hovered_output: None, icon_cache: IconCache::new(), + is_rect_selecting: false, + drag_select_start: Pos2::ZERO, + drag_select_current: Pos2::ZERO, + selection_before_drag: HashSet::new(), + is_alt_copy_drag: false, } } + /// Whether the user is currently dragging selected nodes. + pub fn is_dragging_nodes(&self) -> bool { + self.is_dragging_selection + } + /// Get the currently selected nodes. pub fn selected_nodes(&self) -> &HashSet { &self.selected } + /// Set the selected nodes (used by undo/redo to restore selection). + pub fn set_selected(&mut self, names: HashSet) { + self.selected = names; + } + + /// Clone all selected nodes and their internal/incoming connections. + /// Updates `self.selected` to point to the new clones. + fn perform_alt_copy(&mut self, library: &mut Arc) { + let lib = Arc::make_mut(library); + + // Collect existing names to track uniqueness across the batch. + let mut used_names: HashSet = + lib.root.children.iter().map(|c| c.name.clone()).collect(); + + // Phase 1: Build old_name -> new_name mapping. + let mut name_map: HashMap = HashMap::new(); + let selected_names: Vec = self.selected.iter().cloned().collect(); + + for old_name in &selected_names { + let prefix = extract_name_prefix(old_name); + let new_name = generate_unique_name(prefix, &used_names); + used_names.insert(new_name.clone()); + name_map.insert(old_name.clone(), new_name); + } + + // Phase 2: Clone nodes with new names. + let mut new_nodes = Vec::new(); + for old_name in &selected_names { + if let Some(original) = lib.root.child(old_name) { + let mut cloned = original.clone(); + cloned.name = name_map[old_name].clone(); + new_nodes.push(cloned); + } + } + + // Phase 3: Duplicate connections. + let mut new_connections = Vec::new(); + for conn in &lib.root.connections { + let out_in_selection = name_map.contains_key(&conn.output_node); + let in_in_selection = name_map.contains_key(&conn.input_node); + + if out_in_selection && in_in_selection { + // Internal connection: remap both ends to clones. + new_connections.push(Connection::new( + &name_map[&conn.output_node], + &name_map[&conn.input_node], + &conn.input_port, + )); + } else if in_in_selection { + // Incoming connection: keep output node, remap input to clone. + new_connections.push(Connection::new( + &conn.output_node, + &name_map[&conn.input_node], + &conn.input_port, + )); + } + // Outgoing connections (out_in_selection && !in_in_selection): NOT duplicated. + } + + // Phase 4: Mutate the network. + for node in new_nodes { + lib.root.children.push(node); + } + lib.root.connections.extend(new_connections); + + // Phase 5: Update selection to the clones. + self.selected.clear(); + for new_name in name_map.values() { + self.selected.insert(new_name.clone()); + } + + self.is_alt_copy_drag = true; + } + /// Show the network view. Returns any action that should be handled by the app. - pub fn show(&mut self, ui: &mut egui::Ui, library: &mut NodeLibrary) -> NetworkAction { + /// + /// The `node_errors` map contains per-node error messages for visual feedback. + pub fn show(&mut self, ui: &mut egui::Ui, library: &mut Arc, node_errors: &HashMap) -> NetworkAction { let mut action = NetworkAction::None; let (response, painter) = @@ -218,7 +328,15 @@ impl NetworkView { let is_selected = self.selected.contains(&child.name); let is_rendered = network.rendered_child.as_deref() == Some(&child.name); let drag_output_type = self.creating_connection.as_ref().map(|c| c.output_type.clone()); - self.draw_node(ui.ctx(), &painter, network, child, offset, is_selected, is_rendered, drag_output_type.as_ref()); + let error_msg = node_errors.get(&child.name); + self.draw_node(ui.ctx(), &painter, network, child, offset, is_selected, is_rendered, drag_output_type.as_ref(), error_msg); + + // Show error tooltip on hover + if let Some(msg) = error_msg { + if node_response.hovered() { + node_response.on_hover_text(format!("{}: {}", child.name, msg)); + } + } // Check for output port click (to start connection) // Use normal-sized hit area (no is_connecting inflation for starting) @@ -235,6 +353,7 @@ impl NetworkView { from_node: child.name.clone(), output_type: child.output_type.clone(), to_pos: output_pos, + is_reroute: false, }); } @@ -316,6 +435,7 @@ impl NetworkView { // Handle connection creation end (use inflated hit areas for easy drop) if self.creating_connection.is_some() && ui.input(|i| i.pointer.any_released()) { + let mut connection_made = false; if let Some(hover_pos) = ui.input(|i| i.pointer.hover_pos()) { // Find which input port we're over using inflated hit areas (is_connecting=true) if let Some((node_name, port_name, _)) = @@ -332,11 +452,35 @@ impl NetworkView { node_name, port_name, )); + connection_made = true; } } } } } + + // If no connection was made and cursor is not over any node, open dialog + // (but not for disconnect-and-reroute drags — those just cancel) + if !connection_made { + let over_any_node = network.children.iter().any(|child| { + self.node_rect(child, offset).contains(hover_pos) + }); + if !over_any_node { + if let Some(ref drag) = self.creating_connection { + if !drag.is_reroute { + let grid_pos = self.screen_to_grid(hover_pos, offset); + action = NetworkAction::OpenNodeDialogForConnection { + position: Point::new( + grid_pos.x.round() as f64, + grid_pos.y.round() as f64, + ), + from_node: drag.from_node.clone(), + output_type: drag.output_type.clone(), + }; + } + } + } + } } self.creating_connection = None; } @@ -351,7 +495,7 @@ impl NetworkView { // Handle disconnect-and-reroute (remove old connection, start new drag from upstream) if let Some((conn_idx, from_node_name, output_type)) = disconnect_and_reroute { // Remove the old connection - library.root.connections.remove(conn_idx); + Arc::make_mut(library).root.connections.remove(conn_idx); // Start a new connection drag from the upstream node if let Some(from_node) = library.root.child(&from_node_name) { let output_pos = self.node_output_pos(from_node, offset); @@ -359,10 +503,61 @@ impl NetworkView { from_node: from_node_name, output_type, to_pos: output_pos, + is_reroute: true, }); } } + // --- Drag Selection (rubber band) --- + // Start drag selection when primary-dragging on empty space + if !self.is_rect_selecting + && !self.is_panning + && !self.is_space_pressed + && start_dragging_node.is_none() + && self.creating_connection.is_none() + && response.drag_started_by(egui::PointerButton::Primary) + { + if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { + self.is_rect_selecting = true; + self.drag_select_start = pos; + self.drag_select_current = pos; + if ui.input(|i| i.modifiers.shift) { + self.selection_before_drag = self.selected.clone(); + } else { + self.selection_before_drag.clear(); + self.selected.clear(); + } + } + } + + // Update drag selection rectangle and compute selected nodes + if self.is_rect_selecting { + if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) { + self.drag_select_current = pos; + } + let selection_rect = + Rect::from_two_pos(self.drag_select_start, self.drag_select_current); + self.selected = self.selection_before_drag.clone(); + for child in &library.root.children { + let node_rect = self.node_rect(child, offset); + if selection_rect.intersects(node_rect) { + self.selected.insert(child.name.clone()); + } + } + } + + // End drag selection + if self.is_rect_selecting && ui.input(|i| i.pointer.any_released()) { + self.is_rect_selecting = false; + } + + // Draw drag selection rectangle + if self.is_rect_selecting { + let r = Rect::from_two_pos(self.drag_select_start, self.drag_select_current); + painter.rect_filled(r, 0.0, Color32::from_white_alpha(100)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(100)), egui::StrokeKind::Inside); + } + // Handle selection let had_node_selection = node_to_select.is_some(); if let Some(name) = node_to_select { @@ -387,11 +582,21 @@ impl NetworkView { self.selected.clear(); self.selected.insert(name); } + + // Alt/Option-drag: clone selected nodes (originals stay, clones get dragged) + if ui.input(|i| i.modifiers.alt) { + self.perform_alt_copy(library); + } + self.is_dragging_selection = true; } // Apply drag delta to all selected nodes if self.is_dragging_selection { + // Show copy cursor if this was an alt-copy drag + if self.is_alt_copy_drag { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); + } let pointer_delta = ui.input(|i| { if i.pointer.is_decidedly_dragging() { i.pointer.delta() @@ -401,8 +606,9 @@ impl NetworkView { }); let delta = pointer_delta / (self.pan_zoom.zoom * GRID_CELL_SIZE); if delta != Vec2::ZERO { + let lib = Arc::make_mut(library); for name in &self.selected { - if let Some(node) = library.root.child_mut(name) { + if let Some(node) = lib.root.child_mut(name) { node.position.x += delta.x as f64; node.position.y += delta.y as f64; } @@ -412,34 +618,41 @@ impl NetworkView { // Snap all selected nodes to grid when drag ends if self.is_dragging_selection && ui.input(|i| i.pointer.any_released()) { + let lib = Arc::make_mut(library); for name in &self.selected { - if let Some(node) = library.root.child_mut(name) { + if let Some(node) = lib.root.child_mut(name) { node.position.x = node.position.x.round(); node.position.y = node.position.y.round(); } } self.is_dragging_selection = false; + self.is_alt_copy_drag = false; } // Set rendered node (on double-click) if let Some(name) = node_to_render { - library.root.rendered_child = Some(name); + Arc::make_mut(library).root.rendered_child = Some(name); } - // Create connection if needed + // Create connection if needed (replaces any existing connection to the same input port) if let Some((from, to, port)) = connection_to_create { - library.root.connections.push(Connection::new(from, to, port)); + Arc::make_mut(library).root.connect(Connection::new(from, to, port)); } // Handle delete key for selected nodes (but not when editing text) let wants_keyboard = ui.ctx().wants_keyboard_input(); if !wants_keyboard && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { // Delete selected nodes + let lib = Arc::make_mut(library); for name in &self.selected { // Remove node - library.root.children.retain(|n| &n.name != name); + lib.root.children.retain(|n| &n.name != name); // Remove connections involving this node - library.root.connections.retain(|c| &c.output_node != name && &c.input_node != name); + lib.root.connections.retain(|c| &c.output_node != name && &c.input_node != name); + // If the deleted node was the rendered node, clear the rendered child + if lib.root.rendered_child.as_deref() == Some(name.as_str()) { + lib.root.rendered_child = None; + } } self.selected.clear(); } @@ -725,6 +938,8 @@ impl NetworkView { /// /// If `drag_output_type` is provided, input ports will show visual feedback /// indicating type compatibility with the dragged connection. + /// + /// If `error_msg` is provided, the node will be drawn with an error background color. fn draw_node( &mut self, ctx: &egui::Context, @@ -735,9 +950,15 @@ impl NetworkView { is_selected: bool, is_rendered: bool, drag_output_type: Option<&PortType>, + error_msg: Option<&String>, ) { let rect = self.node_rect(node, offset); - let body_color = self.output_type_color(&node.output_type); + // Use error color if node has an error, otherwise use output type color + let body_color = if error_msg.is_some() { + theme::ERROR_RED + } else { + self.output_type_color(&node.output_type) + }; let z = self.pan_zoom.zoom; // 1. Selection ring (white fill behind, 2px inset) @@ -976,3 +1197,190 @@ fn is_hidden_port(_port_type: &PortType) -> bool { // For now, show all ports. Can be extended to hide certain types. false } + +/// Strip trailing digits from a node name to get the base prefix. +/// +/// Examples: `"rect1"` → `"rect"`, `"ellipse"` → `"ellipse"`, `"a1b2"` → `"a1b"` +fn extract_name_prefix(name: &str) -> &str { + let prefix_end = name.trim_end_matches(|c: char| c.is_ascii_digit()).len(); + if prefix_end == 0 { + name + } else { + &name[..prefix_end] + } +} + +/// Generate a unique name given a prefix and a set of existing names. +/// +/// Always appends a numeric index: `prefix1`, `prefix2`, etc. +fn generate_unique_name(prefix: &str, existing: &HashSet) -> String { + for i in 1..1000 { + let name = format!("{}{}", prefix, i); + if !existing.contains(&name) { + return name; + } + } + // Fallback (shouldn't happen in practice) + format!("{}{}", prefix, 1000) +} + +#[cfg(test)] +mod tests { + use super::*; + use nodebox_core::node::{Node, NodeLibrary}; + + #[test] + fn test_extract_name_prefix() { + assert_eq!(extract_name_prefix("rect1"), "rect"); + assert_eq!(extract_name_prefix("rect"), "rect"); + assert_eq!(extract_name_prefix("ellipse42"), "ellipse"); + assert_eq!(extract_name_prefix("a1b2"), "a1b"); + assert_eq!(extract_name_prefix(""), ""); + // All-digit name: returns the whole name (prefix_end == 0) + assert_eq!(extract_name_prefix("123"), "123"); + } + + #[test] + fn test_generate_unique_name_available() { + let existing = HashSet::new(); + assert_eq!(generate_unique_name("rect", &existing), "rect1"); + } + + #[test] + fn test_generate_unique_name_increments() { + let existing: HashSet = ["rect".into()].into(); + assert_eq!(generate_unique_name("rect", &existing), "rect1"); + + let existing: HashSet = ["rect".into(), "rect1".into()].into(); + assert_eq!(generate_unique_name("rect", &existing), "rect2"); + } + + #[test] + fn test_alt_copy_single_node() { + let mut library = Arc::new(NodeLibrary::new("test")); + { + let lib = Arc::make_mut(&mut library); + lib.root.children.push(Node::new("rect").with_position(1.0, 2.0)); + lib.root.children.push(Node::new("rect1")); // Take up "rect1" so clone becomes "rect2" + } + + let mut view = NetworkView::new(); + view.selected.insert("rect".into()); + + view.perform_alt_copy(&mut library); + + // Original should still exist + assert!(library.root.child("rect").is_some()); + // Clone should exist with incremented name (skips "rect1" which is taken) + assert!(library.root.child("rect2").is_some()); + // Clone should have same position as original + let clone = library.root.child("rect2").unwrap(); + assert_eq!(clone.position.x, 1.0); + assert_eq!(clone.position.y, 2.0); + // Selection should point to clone + assert!(view.selected.contains("rect2")); + assert!(!view.selected.contains("rect")); + assert_eq!(view.selected.len(), 1); + } + + #[test] + fn test_alt_copy_multiple_nodes_with_internal_connections() { + let mut library = Arc::new(NodeLibrary::new("test")); + { + let lib = Arc::make_mut(&mut library); + lib.root.children.push(Node::new("rect1")); + lib.root.children.push(Node::new("translate1")); + lib.root.connections.push(Connection::new("rect1", "translate1", "shape")); + } + + let mut view = NetworkView::new(); + view.selected.insert("rect1".into()); + view.selected.insert("translate1".into()); + + view.perform_alt_copy(&mut library); + + // Should have 4 nodes total + assert_eq!(library.root.children.len(), 4); + // Original connection should still exist + assert!(library.root.connections.iter().any(|c| + c.output_node == "rect1" && c.input_node == "translate1" && c.input_port == "shape" + )); + // Internal connection should be duplicated with new names + // (rect2 -> translate2 or similar) + let new_names: Vec = view.selected.iter().cloned().collect(); + let has_internal_conn = library.root.connections.iter().any(|c| + new_names.contains(&c.output_node) && new_names.contains(&c.input_node) && c.input_port == "shape" + ); + assert!(has_internal_conn, "Internal connection should be duplicated"); + } + + #[test] + fn test_alt_copy_incoming_connections_preserved() { + let mut library = Arc::new(NodeLibrary::new("test")); + { + let lib = Arc::make_mut(&mut library); + lib.root.children.push(Node::new("source1")); // Not selected + lib.root.children.push(Node::new("rect1")); // Selected + lib.root.connections.push(Connection::new("source1", "rect1", "shape")); + } + + let mut view = NetworkView::new(); + view.selected.insert("rect1".into()); + + view.perform_alt_copy(&mut library); + + let clone_name: String = view.selected.iter().next().unwrap().clone(); + // Incoming connection from non-selected node should be duplicated + assert!(library.root.connections.iter().any(|c| + c.output_node == "source1" && c.input_node == clone_name && c.input_port == "shape" + )); + } + + #[test] + fn test_alt_copy_outgoing_connections_not_duplicated() { + let mut library = Arc::new(NodeLibrary::new("test")); + { + let lib = Arc::make_mut(&mut library); + lib.root.children.push(Node::new("rect1")); // Selected + lib.root.children.push(Node::new("target1")); // Not selected + lib.root.connections.push(Connection::new("rect1", "target1", "shape")); + } + + let mut view = NetworkView::new(); + view.selected.insert("rect1".into()); + + view.perform_alt_copy(&mut library); + + let clone_name: String = view.selected.iter().next().unwrap().clone(); + // Outgoing connection should NOT be duplicated + assert!(!library.root.connections.iter().any(|c| + c.output_node == clone_name && c.input_node == "target1" + )); + // Original connection should still exist + assert_eq!(library.root.connections.len(), 1); + } + + #[test] + fn test_alt_copy_name_collision_across_batch() { + let mut library = Arc::new(NodeLibrary::new("test")); + { + let lib = Arc::make_mut(&mut library); + lib.root.children.push(Node::new("rect")); + lib.root.children.push(Node::new("rect1")); + } + + let mut view = NetworkView::new(); + view.selected.insert("rect".into()); + view.selected.insert("rect1".into()); + + view.perform_alt_copy(&mut library); + + // Should have 4 nodes, each with a unique name + assert_eq!(library.root.children.len(), 4); + let names: HashSet = library.root.children.iter().map(|c| c.name.clone()).collect(); + assert_eq!(names.len(), 4); + // rect and rect1 still exist, two new names generated + assert!(names.contains("rect")); + assert!(names.contains("rect1")); + } +} diff --git a/crates/nodebox-desktop/src/node_library.rs b/crates/nodebox-desktop/src/node_library.rs new file mode 100644 index 00000000..b95aa5f1 --- /dev/null +++ b/crates/nodebox-desktop/src/node_library.rs @@ -0,0 +1,1931 @@ +//! Node library browser for creating new nodes. +//! +//! Note: This module is work-in-progress and not yet integrated. + +#![allow(dead_code)] + +use std::sync::Arc; +use eframe::egui; +use nodebox_core::geometry::{Color, Point}; +use nodebox_core::node::{MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; + +/// Available node types that can be created. +pub struct NodeTemplate { + pub name: &'static str, + pub prototype: &'static str, + pub category: &'static str, + pub description: &'static str, +} + +/// List of all available node templates. +pub const NODE_TEMPLATES: &[NodeTemplate] = &[ + // ======================== + // Geometry generators + // ======================== + NodeTemplate { + name: "ellipse", + prototype: "corevector.ellipse", + category: "geometry", + description: "Create an ellipse or circle", + }, + NodeTemplate { + name: "rect", + prototype: "corevector.rect", + category: "geometry", + description: "Create a rectangle", + }, + NodeTemplate { + name: "line", + prototype: "corevector.line", + category: "geometry", + description: "Create a line between two points", + }, + NodeTemplate { + name: "line_angle", + prototype: "corevector.line_angle", + category: "geometry", + description: "Create a line from a point, angle and distance", + }, + NodeTemplate { + name: "polygon", + prototype: "corevector.polygon", + category: "geometry", + description: "Create a regular polygon", + }, + NodeTemplate { + name: "star", + prototype: "corevector.star", + category: "geometry", + description: "Create a star shape", + }, + NodeTemplate { + name: "arc", + prototype: "corevector.arc", + category: "geometry", + description: "Create an arc or pie slice", + }, + NodeTemplate { + name: "quad_curve", + prototype: "corevector.quad_curve", + category: "geometry", + description: "Create a quadratic curve between two points", + }, + NodeTemplate { + name: "grid", + prototype: "corevector.grid", + category: "geometry", + description: "Create a grid of points", + }, + NodeTemplate { + name: "textpath", + prototype: "corevector.textpath", + category: "geometry", + description: "Convert text to a vector path", + }, + NodeTemplate { + name: "connect", + prototype: "corevector.connect", + category: "geometry", + description: "Connect all points in a path", + }, + NodeTemplate { + name: "make_point", + prototype: "corevector.make_point", + category: "geometry", + description: "Create a point from X/Y coordinates", + }, + NodeTemplate { + name: "freehand", + prototype: "corevector.freehand", + category: "geometry", + description: "Draw directly on the canvas", + }, + // Combine nodes + NodeTemplate { + name: "merge", + prototype: "corevector.merge", + category: "geometry", + description: "Combine multiple shapes", + }, + NodeTemplate { + name: "group", + prototype: "corevector.group", + category: "geometry", + description: "Group shapes together", + }, + NodeTemplate { + name: "ungroup", + prototype: "corevector.ungroup", + category: "geometry", + description: "Decompose geometry into its paths", + }, + // Modify nodes + NodeTemplate { + name: "resample", + prototype: "corevector.resample", + category: "geometry", + description: "Resample path points", + }, + NodeTemplate { + name: "wiggle", + prototype: "corevector.wiggle", + category: "geometry", + description: "Add random displacement to points", + }, + NodeTemplate { + name: "align", + prototype: "corevector.align", + category: "geometry", + description: "Align a shape in relation to the origin", + }, + NodeTemplate { + name: "fit", + prototype: "corevector.fit", + category: "geometry", + description: "Fit a shape within bounds", + }, + NodeTemplate { + name: "fit_to", + prototype: "corevector.fit_to", + category: "geometry", + description: "Fit a shape to another shape", + }, + NodeTemplate { + name: "snap", + prototype: "corevector.snap", + category: "geometry", + description: "Snap geometry to a grid", + }, + NodeTemplate { + name: "centroid", + prototype: "corevector.centroid", + category: "geometry", + description: "Calculate the center point of a shape", + }, + NodeTemplate { + name: "point_on_path", + prototype: "corevector.point_on_path", + category: "geometry", + description: "Calculate a point on a path", + }, + NodeTemplate { + name: "scatter", + prototype: "corevector.scatter", + category: "geometry", + description: "Generate points within a shape", + }, + NodeTemplate { + name: "delete", + prototype: "corevector.delete", + category: "geometry", + description: "Delete points or paths within a bounding shape", + }, + NodeTemplate { + name: "sort", + prototype: "corevector.sort", + category: "geometry", + description: "Sort shapes by position or distance", + }, + NodeTemplate { + name: "stack", + prototype: "corevector.stack", + category: "geometry", + description: "Arrange shapes in a layout", + }, + NodeTemplate { + name: "link", + prototype: "corevector.link", + category: "geometry", + description: "Generate a visual link between two shapes", + }, + NodeTemplate { + name: "shape_on_path", + prototype: "corevector.shape_on_path", + category: "geometry", + description: "Copy shapes along a path", + }, + NodeTemplate { + name: "null", + prototype: "corevector.null", + category: "geometry", + description: "Pass through without changes", + }, + // Import nodes + NodeTemplate { + name: "import_svg", + prototype: "corevector.import_svg", + category: "geometry", + description: "Import an SVG file as geometry", + }, + // ======================== + // Transform nodes + // ======================== + NodeTemplate { + name: "translate", + prototype: "corevector.translate", + category: "transform", + description: "Move geometry by offset", + }, + NodeTemplate { + name: "rotate", + prototype: "corevector.rotate", + category: "transform", + description: "Rotate geometry around a point", + }, + NodeTemplate { + name: "scale", + prototype: "corevector.scale", + category: "transform", + description: "Scale geometry", + }, + NodeTemplate { + name: "copy", + prototype: "corevector.copy", + category: "transform", + description: "Create multiple copies", + }, + NodeTemplate { + name: "distribute", + prototype: "corevector.distribute", + category: "transform", + description: "Distribute shapes on a horizontal or vertical axis", + }, + NodeTemplate { + name: "skew", + prototype: "corevector.skew", + category: "transform", + description: "Skew the shape", + }, + NodeTemplate { + name: "reflect", + prototype: "corevector.reflect", + category: "transform", + description: "Mirror geometry around an axis", + }, + // ======================== + // Color nodes + // ======================== + NodeTemplate { + name: "colorize", + prototype: "corevector.colorize", + category: "color", + description: "Set fill and stroke colors", + }, + NodeTemplate { + name: "rgb_color", + prototype: "color.rgb_color", + category: "color", + description: "Create a color from RGB components", + }, + NodeTemplate { + name: "hsb_color", + prototype: "color.hsb_color", + category: "color", + description: "Create a color from HSB components", + }, + NodeTemplate { + name: "gray_color", + prototype: "color.gray_color", + category: "color", + description: "Create a grayscale color", + }, + NodeTemplate { + name: "color", + prototype: "color.color", + category: "color", + description: "Create a color value", + }, + // ======================== + // Math nodes + // ======================== + NodeTemplate { + name: "number", + prototype: "math.number", + category: "math", + description: "Create a number value", + }, + NodeTemplate { + name: "integer", + prototype: "math.integer", + category: "math", + description: "Create an integer value", + }, + NodeTemplate { + name: "boolean", + prototype: "math.boolean", + category: "math", + description: "Create a boolean value", + }, + NodeTemplate { + name: "add", + prototype: "math.add", + category: "math", + description: "Add two numbers", + }, + NodeTemplate { + name: "subtract", + prototype: "math.subtract", + category: "math", + description: "Subtract two numbers", + }, + NodeTemplate { + name: "multiply", + prototype: "math.multiply", + category: "math", + description: "Multiply two numbers", + }, + NodeTemplate { + name: "divide", + prototype: "math.divide", + category: "math", + description: "Divide two numbers", + }, + NodeTemplate { + name: "mod", + prototype: "math.mod", + category: "math", + description: "Modulo of two numbers", + }, + NodeTemplate { + name: "negate", + prototype: "math.negate", + category: "math", + description: "Negate a number", + }, + NodeTemplate { + name: "abs", + prototype: "math.abs", + category: "math", + description: "Absolute value", + }, + NodeTemplate { + name: "sqrt", + prototype: "math.sqrt", + category: "math", + description: "Square root", + }, + NodeTemplate { + name: "pow", + prototype: "math.pow", + category: "math", + description: "Raise to a power", + }, + NodeTemplate { + name: "log", + prototype: "math.log", + category: "math", + description: "Natural logarithm", + }, + NodeTemplate { + name: "ceil", + prototype: "math.ceil", + category: "math", + description: "Round up to integer", + }, + NodeTemplate { + name: "floor", + prototype: "math.floor", + category: "math", + description: "Round down to integer", + }, + NodeTemplate { + name: "round", + prototype: "math.round", + category: "math", + description: "Round to nearest integer", + }, + NodeTemplate { + name: "sin", + prototype: "math.sin", + category: "math", + description: "Sine function", + }, + NodeTemplate { + name: "cos", + prototype: "math.cos", + category: "math", + description: "Cosine function", + }, + NodeTemplate { + name: "radians", + prototype: "math.radians", + category: "math", + description: "Convert degrees to radians", + }, + NodeTemplate { + name: "degrees", + prototype: "math.degrees", + category: "math", + description: "Convert radians to degrees", + }, + NodeTemplate { + name: "pi", + prototype: "math.pi", + category: "math", + description: "The constant pi", + }, + NodeTemplate { + name: "e", + prototype: "math.e", + category: "math", + description: "Euler's number", + }, + NodeTemplate { + name: "even", + prototype: "math.even", + category: "math", + description: "Check if a number is even", + }, + NodeTemplate { + name: "odd", + prototype: "math.odd", + category: "math", + description: "Check if a number is odd", + }, + NodeTemplate { + name: "compare", + prototype: "math.compare", + category: "math", + description: "Compare two values", + }, + NodeTemplate { + name: "logical", + prototype: "math.logical", + category: "math", + description: "Logical AND/OR of two booleans", + }, + NodeTemplate { + name: "angle", + prototype: "math.angle", + category: "math", + description: "Angle between two points", + }, + NodeTemplate { + name: "distance", + prototype: "math.distance", + category: "math", + description: "Distance between two points", + }, + NodeTemplate { + name: "coordinates", + prototype: "math.coordinates", + category: "math", + description: "Point from angle and distance", + }, + NodeTemplate { + name: "math_reflect", + prototype: "math.reflect", + category: "math", + description: "Reflect a point around another", + }, + NodeTemplate { + name: "random_numbers", + prototype: "math.random_numbers", + category: "math", + description: "Generate a list of random numbers", + }, + NodeTemplate { + name: "range", + prototype: "math.range", + category: "math", + description: "Generate a range of numbers", + }, + NodeTemplate { + name: "sample", + prototype: "math.sample", + category: "math", + description: "Generate evenly-spaced samples", + }, + NodeTemplate { + name: "wave", + prototype: "math.wave", + category: "math", + description: "Generate a wave value", + }, + NodeTemplate { + name: "convert_range", + prototype: "math.convert_range", + category: "math", + description: "Map a value from one range to another", + }, + NodeTemplate { + name: "sum", + prototype: "math.sum", + category: "math", + description: "Sum of a list of numbers", + }, + NodeTemplate { + name: "average", + prototype: "math.average", + category: "math", + description: "Average of a list of numbers", + }, + NodeTemplate { + name: "max", + prototype: "math.max", + category: "math", + description: "Maximum of a list of numbers", + }, + NodeTemplate { + name: "min", + prototype: "math.min", + category: "math", + description: "Minimum of a list of numbers", + }, + NodeTemplate { + name: "make_numbers", + prototype: "math.make_numbers", + category: "math", + description: "Parse numbers from a string", + }, + NodeTemplate { + name: "running_total", + prototype: "math.running_total", + category: "math", + description: "Running total of a list", + }, + // ======================== + // String nodes + // ======================== + NodeTemplate { + name: "string", + prototype: "string.string", + category: "string", + description: "Create a string value", + }, + NodeTemplate { + name: "concatenate", + prototype: "string.concatenate", + category: "string", + description: "Join strings together", + }, + NodeTemplate { + name: "make_strings", + prototype: "string.make_strings", + category: "string", + description: "Split a string into a list", + }, + NodeTemplate { + name: "length", + prototype: "string.length", + category: "string", + description: "Length of a string", + }, + NodeTemplate { + name: "word_count", + prototype: "string.word_count", + category: "string", + description: "Count words in a string", + }, + NodeTemplate { + name: "change_case", + prototype: "string.change_case", + category: "string", + description: "Change text case", + }, + NodeTemplate { + name: "format_number", + prototype: "string.format_number", + category: "string", + description: "Format a number as string", + }, + NodeTemplate { + name: "trim", + prototype: "string.trim", + category: "string", + description: "Remove leading/trailing whitespace", + }, + NodeTemplate { + name: "replace", + prototype: "string.replace", + category: "string", + description: "Replace text in a string", + }, + NodeTemplate { + name: "sub_string", + prototype: "string.sub_string", + category: "string", + description: "Extract part of a string", + }, + NodeTemplate { + name: "character_at", + prototype: "string.character_at", + category: "string", + description: "Get character at index", + }, + NodeTemplate { + name: "as_binary_string", + prototype: "string.as_binary_string", + category: "string", + description: "Convert string to binary", + }, + NodeTemplate { + name: "contains", + prototype: "string.contains", + category: "string", + description: "Check if string contains text", + }, + NodeTemplate { + name: "ends_with", + prototype: "string.ends_with", + category: "string", + description: "Check if string ends with text", + }, + NodeTemplate { + name: "starts_with", + prototype: "string.starts_with", + category: "string", + description: "Check if string starts with text", + }, + NodeTemplate { + name: "equals", + prototype: "string.equals", + category: "string", + description: "Check if two strings are equal", + }, + NodeTemplate { + name: "characters", + prototype: "string.characters", + category: "string", + description: "Split string into characters", + }, + NodeTemplate { + name: "random_character", + prototype: "string.random_character", + category: "string", + description: "Generate random characters", + }, + NodeTemplate { + name: "as_binary_list", + prototype: "string.as_binary_list", + category: "string", + description: "Convert string to binary list", + }, + NodeTemplate { + name: "as_number_list", + prototype: "string.as_number_list", + category: "string", + description: "Convert string to number list", + }, + // ======================== + // List nodes + // ======================== + NodeTemplate { + name: "count", + prototype: "list.count", + category: "list", + description: "Count items in a list", + }, + NodeTemplate { + name: "first", + prototype: "list.first", + category: "list", + description: "Get the first item of a list", + }, + NodeTemplate { + name: "second", + prototype: "list.second", + category: "list", + description: "Get the second item of a list", + }, + NodeTemplate { + name: "last", + prototype: "list.last", + category: "list", + description: "Get the last item of a list", + }, + NodeTemplate { + name: "rest", + prototype: "list.rest", + category: "list", + description: "Get all items except the first", + }, + NodeTemplate { + name: "reverse", + prototype: "list.reverse", + category: "list", + description: "Reverse the order of a list", + }, + NodeTemplate { + name: "shuffle", + prototype: "list.shuffle", + category: "list", + description: "Randomize list order", + }, + NodeTemplate { + name: "slice", + prototype: "list.slice", + category: "list", + description: "Take a portion of a list", + }, + NodeTemplate { + name: "shift", + prototype: "list.shift", + category: "list", + description: "Shift list items by offset", + }, + NodeTemplate { + name: "repeat", + prototype: "list.repeat", + category: "list", + description: "Repeat list items", + }, + NodeTemplate { + name: "list_sort", + prototype: "list.sort", + category: "list", + description: "Sort a list", + }, + NodeTemplate { + name: "pick", + prototype: "list.pick", + category: "list", + description: "Pick random items from a list", + }, + NodeTemplate { + name: "cull", + prototype: "list.cull", + category: "list", + description: "Filter list items by boolean pattern", + }, + NodeTemplate { + name: "take_every", + prototype: "list.take_every", + category: "list", + description: "Take every Nth item", + }, + NodeTemplate { + name: "distinct", + prototype: "list.distinct", + category: "list", + description: "Remove duplicate items", + }, + NodeTemplate { + name: "switch", + prototype: "list.switch", + category: "list", + description: "Select from multiple inputs", + }, + NodeTemplate { + name: "combine", + prototype: "list.combine", + category: "list", + description: "Combine multiple lists into one", + }, + NodeTemplate { + name: "keys", + prototype: "list.keys", + category: "list", + description: "Get the keys from a list of maps", + }, + NodeTemplate { + name: "zip_map", + prototype: "list.zip_map", + category: "list", + description: "Combine keys and values into a map", + }, + // ======================== + // Core nodes + // ======================== + NodeTemplate { + name: "frame", + prototype: "core.frame", + category: "core", + description: "Get the current animation frame", + }, + // ======================== + // Data nodes + // ======================== + NodeTemplate { + name: "import_text", + prototype: "data.import_text", + category: "data", + description: "Import lines from a text file", + }, + NodeTemplate { + name: "import_csv", + prototype: "data.import_csv", + category: "data", + description: "Import a CSV file as structured data", + }, + NodeTemplate { + name: "make_table", + prototype: "data.make_table", + category: "data", + description: "Build a data table from lists", + }, + NodeTemplate { + name: "lookup", + prototype: "data.lookup", + category: "data", + description: "Look up a value by key in data", + }, + NodeTemplate { + name: "filter_data", + prototype: "data.filter_data", + category: "data", + description: "Filter data rows by key/value comparison", + }, + // ======================== + // Network nodes + // ======================== + NodeTemplate { + name: "http_get", + prototype: "network.http_get", + category: "network", + description: "Fetch content from a URL", + }, + NodeTemplate { + name: "encode_url", + prototype: "network.encode_url", + category: "network", + description: "Percent-encode a URL string", + }, +]; + +/// The node library browser widget. +pub struct NodeLibraryBrowser { + search_text: String, + selected_category: Option, +} + +impl Default for NodeLibraryBrowser { + fn default() -> Self { + Self::new() + } +} + +impl NodeLibraryBrowser { + pub fn new() -> Self { + Self { + search_text: String::new(), + selected_category: None, + } + } + + /// Show the library browser and return the name of any node created. + pub fn show(&mut self, ui: &mut egui::Ui, library: &mut Arc) -> Option { + let mut created_node = None; + + // Search box + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.search_text); + }); + ui.add_space(5.0); + + // Category filter buttons + ui.horizontal_wrapped(|ui| { + let categories = ["geometry", "transform", "color", "math", "string", "list", "core", "data", "network"]; + for cat in categories { + let is_selected = self.selected_category.as_deref() == Some(cat); + if ui.selectable_label(is_selected, cat).clicked() { + if is_selected { + self.selected_category = None; + } else { + self.selected_category = Some(cat.to_string()); + } + } + } + if ui.selectable_label(self.selected_category.is_none() && self.search_text.is_empty(), "all").clicked() { + self.selected_category = None; + self.search_text.clear(); + } + }); + ui.separator(); + + // Node list + egui::ScrollArea::vertical().show(ui, |ui| { + for template in NODE_TEMPLATES { + // Filter by category + if let Some(ref cat) = self.selected_category { + if template.category != cat { + continue; + } + } + + // Filter by search text + if !self.search_text.is_empty() { + let search = self.search_text.to_lowercase(); + if !template.name.to_lowercase().contains(&search) + && !template.description.to_lowercase().contains(&search) + { + continue; + } + } + + // Display node button + ui.horizontal(|ui| { + if ui.button("+").clicked() { + // Calculate position (offset from last node or default) + let pos = if let Some(last_child) = library.root.children.last() { + Point::new(last_child.position.x + 180.0, last_child.position.y) + } else { + Point::new(50.0, 50.0) + }; + // Create the node + let node = create_node_from_template(template, library, pos); + let node_name = node.name.clone(); + Arc::make_mut(library).root.children.push(node); + created_node = Some(node_name); + } + ui.label(template.name); + ui.label(format!("({})", template.category)).on_hover_text(template.description); + }); + } + }); + + created_node + } +} + +/// Check if the first input port of a node template is directly compatible +/// with the given output type. Uses strict rules (no string conversion, +/// no number→point promotion) — only same-type, List wildcard, and Int↔Float. +pub fn template_has_compatible_input(template: &NodeTemplate, output_type: &PortType) -> bool { + let temp_lib = NodeLibrary::new("_temp"); + let node = create_node_from_template(template, &temp_lib, Point::ZERO); + node.inputs + .first() + .is_some_and(|port| is_directly_compatible(output_type, &port.port_type)) +} + +/// Strict type compatibility for dialog filtering. +/// Only allows: same type, List wildcard, and Int↔Float. +/// Excludes the broad everything→String and Number→Point rules. +fn is_directly_compatible(output_type: &PortType, input_type: &PortType) -> bool { + if output_type == input_type { + return true; + } + // List input accepts any type + if matches!(input_type, PortType::List) { + return true; + } + // List output connects to any input + if matches!(output_type, PortType::List) { + return true; + } + // Int <-> Float + if matches!(output_type, PortType::Int) && matches!(input_type, PortType::Float) { + return true; + } + if matches!(output_type, PortType::Float) && matches!(input_type, PortType::Int) { + return true; + } + false +} + +/// Create a new node from a template. +pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, position: Point) -> Node { + // Generate unique name + let base_name = template.name; + let name = library.root.unique_child_name(base_name); + + // Create node with appropriate ports based on prototype + // Derive function from prototype: "corevector.ellipse" -> "corevector/ellipse" + let function = template.prototype.replacen('.', "/", 1); + let mut node = Node::new(&name) + .with_prototype(template.prototype) + .with_function(function) + .with_category(template.category) + .with_position(position.x, position.y); + + // Add ports based on node type. + // Every arm must call .with_output_type() — the debug_assert below catches omissions. + let mut matched = true; + match template.name { + // ======================== + // Geometry generators + // ======================== + "ellipse" => { + node = node + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_output_type(PortType::Geometry); + } + "rect" => { + node = node + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::point("roundness", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + "line" => { + node = node + .with_input(Port::point("point1", Point::ZERO)) + .with_input(Port::point("point2", Point::new(100.0, 100.0))) + .with_input(Port::int("points", 2).with_min(0.0)) + .with_output_type(PortType::Geometry); + } + "line_angle" => { + node = node + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("angle", 0.0)) + .with_input(Port::float("distance", 100.0)) + .with_input(Port::int("points", 2).with_min(2.0)) + .with_output_type(PortType::Geometry); + } + "polygon" => { + node = node + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("radius", 100.0)) + .with_input(Port::int("sides", 3).with_min(3.0)) + .with_input(Port::boolean("align", false)) + .with_output_type(PortType::Geometry); + } + "star" => { + node = node + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::int("points", 20).with_min(2.0)) + .with_input(Port::float("outer", 200.0)) + .with_input(Port::float("inner", 100.0)) + .with_output_type(PortType::Geometry); + } + "arc" => { + node = node + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::float("start_angle", 0.0)) + .with_input(Port::float("degrees", 45.0)) + .with_input(Port::menu("type", "pie", vec![ + MenuItem::new("pie", "Pie"), + MenuItem::new("chord", "Chord"), + MenuItem::new("open", "Open"), + ])) + .with_output_type(PortType::Geometry); + } + "quad_curve" => { + node = node + .with_input(Port::point("point1", Point::ZERO)) + .with_input(Port::point("point2", Point::new(100.0, 0.0))) + .with_input(Port::float("t", 50.0)) + .with_input(Port::float("distance", 50.0)) + .with_output_type(PortType::Geometry); + } + "grid" => { + node = node + .with_input(Port::int("columns", 10).with_min(1.0)) + .with_input(Port::int("rows", 10).with_min(1.0)) + .with_input(Port::float("width", 300.0)) + .with_input(Port::float("height", 300.0)) + .with_input(Port::point("position", Point::ZERO)) + .with_output_type(PortType::Point) + .with_output_range(PortRange::List); + } + "textpath" => { + node = node + .with_input(Port::string("text", "hello")) + .with_input(Port::string("font_name", "Verdana").with_widget(Widget::Font)) + .with_input(Port::float("font_size", 24.0)) + .with_input(Port::menu("align", "CENTER", vec![ + MenuItem::new("LEFT", "Left"), + MenuItem::new("CENTER", "Center"), + MenuItem::new("RIGHT", "Right"), + MenuItem::new("JUSTIFY", "Justify"), + ])) + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 0.0)) + .with_output_type(PortType::Geometry); + } + "connect" => { + node = node + .with_input(Port::new("points", PortType::Point).with_port_range(PortRange::List)) + .with_input(Port::boolean("closed", false)) + .with_output_type(PortType::Geometry); + } + "make_point" => { + node = node + .with_input(Port::float("x", 0.0)) + .with_input(Port::float("y", 0.0)) + .with_output_type(PortType::Point); + } + "freehand" => { + node = node + .with_input(Port::string("path", "")) + .with_output_type(PortType::Geometry); + } + // Combine / structural + "merge" | "group" => { + node = node + .with_input(Port::geometry("shapes")) + .with_output_type(PortType::Geometry); + } + "ungroup" => { + node = node + .with_input(Port::geometry("shape")) + .with_output_type(PortType::Geometry); + } + "null" => { + node = node + .with_input(Port::geometry("shape")) + .with_output_type(PortType::Geometry); + } + // Modify / filter geometry + "resample" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::menu("method", "length", vec![ + MenuItem::new("length", "By length"), + MenuItem::new("amount", "By amount"), + ])) + .with_input(Port::float("length", 10.0).with_min(1.0)) + .with_input(Port::int("points", 10).with_min(1.0)) + .with_input(Port::boolean("per_contour", false)) + .with_output_type(PortType::Geometry); + } + "wiggle" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::menu("scope", "points", vec![ + MenuItem::new("points", "Points"), + MenuItem::new("contours", "Contours"), + MenuItem::new("paths", "Paths"), + ])) + .with_input(Port::point("offset", Point::new(10.0, 10.0))) + .with_input(Port::int("seed", 0)) + .with_output_type(PortType::Geometry); + } + "align" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::menu("halign", "center", vec![ + MenuItem::new("left", "Left"), + MenuItem::new("center", "Center"), + MenuItem::new("right", "Right"), + ])) + .with_input(Port::menu("valign", "middle", vec![ + MenuItem::new("top", "Top"), + MenuItem::new("middle", "Middle"), + MenuItem::new("bottom", "Bottom"), + ])) + .with_output_type(PortType::Geometry); + } + "fit" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::boolean("keep_proportions", true)) + .with_output_type(PortType::Geometry); + } + "fit_to" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::geometry("bounding")) + .with_input(Port::boolean("keep_proportions", true)) + .with_output_type(PortType::Geometry); + } + "snap" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::float("distance", 10.0)) + .with_input(Port::float("strength", 1.0)) + .with_input(Port::point("position", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + "centroid" => { + node = node + .with_input(Port::geometry("shape")) + .with_output_type(PortType::Point); + } + "point_on_path" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::float("t", 0.0)) + .with_output_type(PortType::Point); + } + "scatter" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::int("amount", 10)) + .with_input(Port::int("seed", 0)) + .with_output_type(PortType::Point) + .with_output_range(PortRange::List); + } + "delete" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::geometry("bounding")) + .with_input(Port::menu("scope", "points", vec![ + MenuItem::new("points", "Points"), + MenuItem::new("paths", "Paths"), + ])) + .with_input(Port::menu("operation", "selected", vec![ + MenuItem::new("selected", "Selected"), + MenuItem::new("non-selected", "Non-selected"), + ])) + .with_output_type(PortType::Geometry); + } + "sort" => { + node = node + .with_input(Port::geometry("shapes")) + .with_input(Port::menu("order_by", "x", vec![ + MenuItem::new("x", "X"), + MenuItem::new("y", "Y"), + MenuItem::new("distance", "Distance"), + MenuItem::new("angle", "Angle"), + ])) + .with_input(Port::point("position", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + "stack" => { + node = node + .with_input(Port::geometry("shapes")) + .with_input(Port::menu("direction", "east", vec![ + MenuItem::new("east", "East"), + MenuItem::new("west", "West"), + MenuItem::new("north", "North"), + MenuItem::new("south", "South"), + ])) + .with_input(Port::float("margin", 0.0)) + .with_output_type(PortType::Geometry); + } + "link" => { + node = node + .with_input(Port::geometry("shape1")) + .with_input(Port::geometry("shape2")) + .with_input(Port::menu("orientation", "horizontal", vec![ + MenuItem::new("horizontal", "Horizontal"), + MenuItem::new("vertical", "Vertical"), + ])) + .with_output_type(PortType::Geometry); + } + "shape_on_path" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::geometry("path")) + .with_input(Port::int("amount", 1)) + .with_input(Port::float("spacing", 20.0)) + .with_input(Port::float("margin", 0.0)) + .with_output_type(PortType::Geometry); + } + // Import + "import_svg" => { + node = node + .with_input(Port::string("file", "").with_widget(Widget::File)) + .with_input(Port::boolean("centered", true)) + .with_input(Port::point("position", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + // ======================== + // Transform nodes + // ======================== + "translate" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::point("translate", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + "rotate" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::float("angle", 0.0)) + .with_input(Port::point("origin", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + "scale" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::point("scale", Point::new(100.0, 100.0))) + .with_input(Port::point("origin", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + "copy" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::int("copies", 1).with_min(0.0)) + .with_input(Port::menu("order", "tsr", vec![ + MenuItem::new("srt", "Scale Rot Trans"), + MenuItem::new("str", "Scale Trans Rot"), + MenuItem::new("rst", "Rot Scale Trans"), + MenuItem::new("rtr", "Rot Trans Scale"), + MenuItem::new("tsr", "Trans Scale Rot"), + MenuItem::new("trs", "Trans Rot Scale"), + ])) + .with_input(Port::point("translate", Point::ZERO)) + .with_input(Port::float("rotate", 0.0)) + .with_input(Port::point("scale", Point::new(100.0, 100.0))) + .with_output_type(PortType::Geometry); + } + "distribute" => { + node = node + .with_input(Port::geometry("shapes").with_port_range(PortRange::List)) + .with_input(Port::menu("horizontal", "none", vec![ + MenuItem::new("none", "No Change"), + MenuItem::new("left", "Left"), + MenuItem::new("center", "Center"), + MenuItem::new("right", "Right"), + ])) + .with_input(Port::menu("vertical", "none", vec![ + MenuItem::new("none", "No Change"), + MenuItem::new("top", "Top"), + MenuItem::new("middle", "Middle"), + MenuItem::new("bottom", "Bottom"), + ])) + .with_output_range(PortRange::List); + } + "skew" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::point("skew", Point::ZERO)) + .with_input(Port::point("origin", Point::ZERO)) + .with_output_type(PortType::Geometry); + } + "reflect" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("angle", 0.0)) + .with_input(Port::boolean("keep_original", true)) + .with_output_type(PortType::Geometry); + } + // ======================== + // Color nodes + // ======================== + "colorize" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(0.5, 0.5, 0.5))) + .with_input(Port::color("stroke", Color::BLACK)) + .with_input(Port::float("strokeWidth", 1.0)) + .with_output_type(PortType::Geometry); + } + "rgb_color" => { + node = node + .with_input(Port::float("red", 0.0)) + .with_input(Port::float("green", 0.0)) + .with_input(Port::float("blue", 0.0)) + .with_input(Port::float("alpha", 255.0)) + .with_input(Port::float("range", 255.0)) + .with_output_type(PortType::Color); + } + "hsb_color" => { + node = node + .with_input(Port::float("hue", 0.0)) + .with_input(Port::float("saturation", 0.0)) + .with_input(Port::float("brightness", 0.0)) + .with_input(Port::float("alpha", 255.0)) + .with_input(Port::float("range", 255.0)) + .with_output_type(PortType::Color); + } + "gray_color" => { + node = node + .with_input(Port::float("gray", 0.0)) + .with_input(Port::float("alpha", 255.0)) + .with_input(Port::float("range", 255.0)) + .with_output_type(PortType::Color); + } + "color" => { + node = node + .with_input(Port::color("color", Color::BLACK)) + .with_output_type(PortType::Color); + } + // ======================== + // Math nodes + // ======================== + "number" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Float); + } + "integer" => { + node = node + .with_input(Port::int("value", 0)) + .with_output_type(PortType::Int); + } + "boolean" => { + node = node + .with_input(Port::boolean("value", false)) + .with_output_type(PortType::Boolean); + } + "add" | "subtract" | "multiply" | "divide" => { + node = node + .with_input(Port::float("value1", 0.0)) + .with_input(Port::float("value2", 0.0)) + .with_output_type(PortType::Float); + } + "mod" => { + node = node + .with_input(Port::float("value1", 0.0)) + .with_input(Port::float("value2", 1.0)) + .with_output_type(PortType::Float); + } + "negate" | "abs" | "sqrt" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Float); + } + "pow" => { + node = node + .with_input(Port::float("value1", 0.0)) + .with_input(Port::float("value2", 2.0)) + .with_output_type(PortType::Float); + } + "log" => { + node = node + .with_input(Port::float("value", 1.0)) + .with_output_type(PortType::Float); + } + "ceil" | "floor" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Float); + } + "round" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Int); + } + "sin" | "cos" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Float); + } + "radians" => { + node = node + .with_input(Port::float("degrees", 0.0)) + .with_output_type(PortType::Float); + } + "degrees" => { + node = node + .with_input(Port::float("radians", 0.0)) + .with_output_type(PortType::Float); + } + "pi" | "e" => { + node = node.with_output_type(PortType::Float); + } + "even" | "odd" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Boolean); + } + "compare" => { + node = node + .with_input(Port::float("value1", 0.0)) + .with_input(Port::float("value2", 0.0)) + .with_input(Port::menu("comparator", "<", vec![ + MenuItem::new("<", "Less Than"), + MenuItem::new(">", "Greater Than"), + MenuItem::new("<=", "Less or Equal"), + MenuItem::new(">=", "Greater or Equal"), + MenuItem::new("==", "Equal"), + MenuItem::new("!=", "Not Equal"), + ])) + .with_output_type(PortType::Boolean); + } + "logical" => { + node = node + .with_input(Port::boolean("boolean1", false)) + .with_input(Port::boolean("boolean2", false)) + .with_input(Port::menu("comparator", "or", vec![ + MenuItem::new("or", "Or"), + MenuItem::new("and", "And"), + ])) + .with_output_type(PortType::Boolean); + } + "angle" | "distance" => { + node = node + .with_input(Port::point("point1", Point::ZERO)) + .with_input(Port::point("point2", Point::new(100.0, 100.0))) + .with_output_type(PortType::Float); + } + "coordinates" => { + node = node + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("angle", 0.0)) + .with_input(Port::float("distance", 100.0)) + .with_output_type(PortType::Point); + } + "math_reflect" => { + node = node + .with_input(Port::point("point1", Point::ZERO)) + .with_input(Port::point("point2", Point::new(100.0, 100.0))) + .with_input(Port::float("angle", 0.0)) + .with_input(Port::float("distance", 1.0)) + .with_output_type(PortType::Point); + } + "random_numbers" => { + node = node + .with_input(Port::int("amount", 10)) + .with_input(Port::float("start", 0.0)) + .with_input(Port::float("end", 100.0)) + .with_input(Port::int("seed", 0)) + .with_output_type(PortType::Float) + .with_output_range(PortRange::List); + } + "range" => { + node = node + .with_input(Port::float("start", 0.0)) + .with_input(Port::float("end", 10.0)) + .with_input(Port::float("step", 1.0)) + .with_output_type(PortType::Float) + .with_output_range(PortRange::List); + } + "sample" => { + node = node + .with_input(Port::int("amount", 10)) + .with_input(Port::float("start", 0.0)) + .with_input(Port::float("end", 100.0)) + .with_output_type(PortType::Float) + .with_output_range(PortRange::List); + } + "wave" => { + node = node + .with_input(Port::float("min", 0.0)) + .with_input(Port::float("max", 100.0)) + .with_input(Port::float("period", 60.0)) + .with_input(Port::float("offset", 0.0)) + .with_input(Port::menu("type", "sine", vec![ + MenuItem::new("sine", "Sine"), + MenuItem::new("square", "Square"), + MenuItem::new("triangle", "Triangle"), + MenuItem::new("sawtooth", "Sawtooth"), + ])) + .with_output_type(PortType::Float); + } + "convert_range" => { + node = node + .with_input(Port::float("value", 50.0)) + .with_input(Port::float("source_start", 0.0)) + .with_input(Port::float("source_end", 100.0)) + .with_input(Port::float("target_start", 0.0)) + .with_input(Port::float("target_end", 1.0)) + .with_input(Port::menu("method", "clamp", vec![ + MenuItem::new("clamp", "Clamp"), + MenuItem::new("wrap", "Wrap"), + MenuItem::new("mirror", "Mirror"), + MenuItem::new("ignore", "Ignore"), + ])) + .with_output_type(PortType::Float); + } + "sum" | "average" | "max" | "min" => { + node = node + .with_input(Port::new("values", PortType::Float).with_port_range(PortRange::List)) + .with_output_type(PortType::Float); + } + "make_numbers" => { + node = node + .with_input(Port::string("string", "11;22;33")) + .with_input(Port::string("separator", ";")) + .with_output_type(PortType::Float) + .with_output_range(PortRange::List); + } + "running_total" => { + node = node + .with_input(Port::new("values", PortType::Float).with_port_range(PortRange::List)) + .with_output_type(PortType::Float) + .with_output_range(PortRange::List); + } + // ======================== + // String nodes + // ======================== + "string" => { + node = node + .with_input(Port::string("value", "")) + .with_output_type(PortType::String); + } + "concatenate" => { + node = node + .with_input(Port::string("string1", "")) + .with_input(Port::string("string2", "")) + .with_input(Port::string("string3", "")) + .with_input(Port::string("string4", "")) + .with_output_type(PortType::String); + } + "make_strings" => { + node = node + .with_input(Port::string("string", "Alpha;Beta;Gamma")) + .with_input(Port::string("separator", ";")) + .with_output_type(PortType::String) + .with_output_range(PortRange::List); + } + "length" | "word_count" => { + node = node + .with_input(Port::string("string", "")) + .with_output_type(PortType::Int); + } + "change_case" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::menu("method", "uppercase", vec![ + MenuItem::new("uppercase", "Uppercase"), + MenuItem::new("lowercase", "Lowercase"), + MenuItem::new("titlecase", "Title Case"), + ])) + .with_output_type(PortType::String); + } + "format_number" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_input(Port::string("format", "%.2f")) + .with_output_type(PortType::String); + } + "trim" => { + node = node + .with_input(Port::string("string", "")) + .with_output_type(PortType::String); + } + "replace" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("old", "")) + .with_input(Port::string("new", "")) + .with_output_type(PortType::String); + } + "sub_string" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::int("start", 0)) + .with_input(Port::int("end", 4)) + .with_input(Port::boolean("end_offset", false)) + .with_output_type(PortType::String); + } + "character_at" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::int("index", 0)) + .with_output_type(PortType::String); + } + "as_binary_string" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("digit_separator", "")) + .with_input(Port::string("byte_separator", " ")) + .with_output_type(PortType::String); + } + "contains" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("contains", "")) + .with_output_type(PortType::Boolean); + } + "ends_with" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("ends_with", "")) + .with_output_type(PortType::Boolean); + } + "starts_with" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("starts_with", "")) + .with_output_type(PortType::Boolean); + } + "equals" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("equals", "")) + .with_input(Port::boolean("case_sensitive", false)) + .with_output_type(PortType::Boolean); + } + "characters" => { + node = node + .with_input(Port::string("string", "")) + .with_output_type(PortType::String) + .with_output_range(PortRange::List); + } + "random_character" => { + node = node + .with_input(Port::string("characters", "abcdefghijklmnopqrstuvwxyz")) + .with_input(Port::int("amount", 10)) + .with_input(Port::int("seed", 0)) + .with_output_type(PortType::String) + .with_output_range(PortRange::List); + } + "as_binary_list" => { + node = node + .with_input(Port::string("string", "")) + .with_output_type(PortType::String) + .with_output_range(PortRange::List); + } + "as_number_list" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::int("radix", 10)) + .with_input(Port::boolean("padding", true)) + .with_output_type(PortType::String) + .with_output_range(PortRange::List); + } + // ======================== + // List nodes + // ======================== + "count" | "first" | "reverse" | "shuffle" | "slice" => { + node = node.with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)); + match template.name { + "count" => { + node = node.with_output_type(PortType::Int); + } + "first" => { + node = node.with_output_type(PortType::List); + } + "reverse" => { + node = node + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "shuffle" => { + node = node + .with_input(Port::int("seed", 0)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "slice" => { + node = node + .with_input(Port::int("start_index", 0)) + .with_input(Port::int("size", 10)) + .with_input(Port::boolean("invert", false)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + _ => {} + } + } + "second" | "last" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::List); + } + "rest" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "shift" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::int("amount", 1)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "repeat" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::int("amount", 1)) + .with_input(Port::boolean("per_item", false)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "list_sort" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "pick" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::int("amount", 5)) + .with_input(Port::int("seed", 0)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "cull" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::new("booleans", PortType::Boolean).with_port_range(PortRange::List)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "take_every" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::int("n", 1)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "distinct" => { + node = node + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "switch" => { + node = node + .with_input(Port::int("index", 0)) + .with_input(Port::new("input1", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::new("input2", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); + } + "combine" => { + node = node + .with_input(Port::geometry("list1")) + .with_input(Port::geometry("list2")) + .with_input(Port::geometry("list3")) + .with_output_type(PortType::Geometry); + } + "keys" => { + node = node + .with_input(Port::new("maps", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::Data) + .with_output_range(PortRange::List); + } + "zip_map" => { + node = node + .with_input(Port::new("keys", PortType::String).with_port_range(PortRange::List)) + .with_input(Port::new("values", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::Data); + } + // ======================== + // Core nodes + // ======================== + "frame" => { + node = node.with_output_type(PortType::Float); + } + // ======================== + // Data nodes + // ======================== + "import_text" => { + node = node + .with_input(Port::string("file", "").with_widget(Widget::File)) + .with_output_type(PortType::String) + .with_output_range(PortRange::List); + } + "import_csv" => { + node = node + .with_input(Port::string("file", "").with_widget(Widget::File)) + .with_input(Port::menu("delimiter", "comma", vec![ + MenuItem::new("comma", "Comma"), + MenuItem::new("semicolon", "Semicolon"), + MenuItem::new("colon", "Colon"), + MenuItem::new("tab", "Tab"), + MenuItem::new("space", "Space"), + ])) + .with_input(Port::menu("quotes", "double", vec![ + MenuItem::new("double", "\""), + MenuItem::new("single", "'"), + ])) + .with_input(Port::menu("number_separator", "period", vec![ + MenuItem::new("period", "."), + MenuItem::new("comma", ","), + ])) + .with_output_type(PortType::Data) + .with_output_range(PortRange::List); + } + "make_table" => { + node = node + .with_input(Port::string("headers", "alpha;beta")) + .with_input(Port::new("list1", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::new("list2", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::new("list3", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::new("list4", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::new("list5", PortType::List).with_port_range(PortRange::List)) + .with_input(Port::new("list6", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::Data) + .with_output_range(PortRange::List); + } + "lookup" => { + node = node + .with_input(Port::new("list", PortType::Data)) + .with_input(Port::string("key", "x")) + .with_output_type(PortType::Data); + } + "filter_data" => { + node = node + .with_input(Port::new("data", PortType::Data).with_port_range(PortRange::List)) + .with_input(Port::string("key", "name")) + .with_input(Port::menu("op", "=", vec![ + MenuItem::new("=", "= Equal To"), + MenuItem::new("!=", "!= Not Equal To"), + MenuItem::new(">", "> Greater Than"), + MenuItem::new(">=", ">= Greater or Equal"), + MenuItem::new("<", "< Smaller Than"), + MenuItem::new("<=", "<= Smaller or Equal"), + ])) + .with_input(Port::string("value", "")) + .with_output_type(PortType::Data) + .with_output_range(PortRange::List); + } + // ======================== + // Network nodes + // ======================== + "http_get" => { + node = node + .with_input(Port::string("url", "")) + .with_output_type(PortType::String); + } + "encode_url" => { + node = node + .with_input(Port::string("value", "")) + .with_output_type(PortType::String); + } + _ => { + matched = false; + } + } + + debug_assert!( + matched, + "Node template '{}' is registered but has no match arm in create_node_from_template. \ + Add an arm with .with_output_type() to set the correct output type.", + template.name + ); + + node +} diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-desktop/src/node_selection_dialog.rs similarity index 60% rename from crates/nodebox-gui/src/node_selection_dialog.rs rename to crates/nodebox-desktop/src/node_selection_dialog.rs index 67394d81..012c8646 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-desktop/src/node_selection_dialog.rs @@ -2,13 +2,13 @@ use eframe::egui::{self, Color32, Key, Vec2}; use nodebox_core::geometry::Point; -use nodebox_core::node::{Node, NodeLibrary}; +use nodebox_core::node::{Node, NodeLibrary, PortType}; use crate::icon_cache::IconCache; -use crate::node_library::{NodeTemplate, NODE_TEMPLATES, create_node_from_template}; +use crate::node_library::{NodeTemplate, NODE_TEMPLATES, create_node_from_template, template_has_compatible_input}; use crate::theme; /// Categories for filtering nodes. -const CATEGORIES: &[&str] = &["All", "geometry", "transform", "color"]; +const CATEGORIES: &[&str] = &["All", "geometry", "transform", "color", "math", "string", "list", "core", "data", "network"]; /// The modal node selection dialog. pub struct NodeSelectionDialog { @@ -18,14 +18,16 @@ pub struct NodeSelectionDialog { search_query: String, /// Selected category (None = All). selected_category: Option, - /// Filtered list of node indices. - filtered_indices: Vec, + /// Filtered list of (template index, match score) pairs, sorted by score descending. + filtered_indices: Vec<(usize, u32)>, /// Currently selected index in filtered list. selected_index: usize, /// Position where the node should be created. create_position: Point, /// Whether search input should be focused. focus_search: bool, + /// When set, only show nodes that have an input port compatible with this output type. + filter_output_type: Option, } impl Default for NodeSelectionDialog { @@ -45,6 +47,7 @@ impl NodeSelectionDialog { selected_index: 0, create_position: Point::ZERO, focus_search: false, + filter_output_type: None, }; dialog.update_filtered_list(); dialog @@ -58,6 +61,20 @@ impl NodeSelectionDialog { self.selected_index = 0; self.create_position = position; self.focus_search = true; + self.filter_output_type = None; + self.update_filtered_list(); + } + + /// Open the dialog at the given position, filtered to show only nodes + /// that have an input port compatible with the given output type. + pub fn open_for_connection(&mut self, position: Point, output_type: PortType) { + self.visible = true; + self.search_query.clear(); + self.selected_category = None; + self.selected_index = 0; + self.create_position = position; + self.focus_search = true; + self.filter_output_type = Some(output_type); self.update_filtered_list(); } @@ -65,14 +82,23 @@ impl NodeSelectionDialog { pub fn close(&mut self) { self.visible = false; self.search_query.clear(); + self.filter_output_type = None; } /// Update the filtered list based on search query and category. + /// Results are sorted by match score (best matches first). fn update_filtered_list(&mut self) { self.filtered_indices.clear(); let query = self.search_query.to_lowercase(); for (i, template) in NODE_TEMPLATES.iter().enumerate() { + // Filter by compatible input port type (if set) + if let Some(ref output_type) = self.filter_output_type { + if !template_has_compatible_input(template, output_type) { + continue; + } + } + // Filter by category if let Some(ref cat) = self.selected_category { if template.category != cat { @@ -80,42 +106,72 @@ impl NodeSelectionDialog { } } - // Filter by search query + // Filter and score by search query if !query.is_empty() { - let matches = self.fuzzy_match(template, &query); - if !matches { - continue; + if let Some(score) = self.match_score(template, &query) { + self.filtered_indices.push((i, score)); } + } else { + self.filtered_indices.push((i, 0)); } - - self.filtered_indices.push(i); } + // Sort by score descending; stable sort preserves template order for equal scores. + self.filtered_indices.sort_by(|a, b| b.1.cmp(&a.1)); + // Reset selection if out of bounds if self.selected_index >= self.filtered_indices.len() { self.selected_index = 0; } } - /// Perform fuzzy matching on a template. - fn fuzzy_match(&self, template: &NodeTemplate, query: &str) -> bool { + /// Compute a match score for a template against the search query. + /// Returns `None` if the template does not match, or `Some(score)` where + /// higher scores indicate better matches. + fn match_score(&self, template: &NodeTemplate, query: &str) -> Option { let name = template.name.to_lowercase(); let desc = template.description.to_lowercase(); - // Exact start match + // Tier 1: Exact name match + if name == query { + return Some(100); + } + + // Tier 2: Name starts with query (prefix match) if name.starts_with(query) { - return true; + return Some(80); } - // Contains match - if name.contains(query) || desc.contains(query) { - return true; + // Tier 3: Name contains query (substring match) + if name.contains(query) { + return Some(60); } - // First letters match (e.g., "rc" matches "rect create") - let name_chars: Vec = name.chars().collect(); + // Tier 4: Word-initial match (e.g., "rn" matches "random_numbers") let query_chars: Vec = query.chars().collect(); + let initials: Vec = name + .split('_') + .filter_map(|w| w.chars().next()) + .collect(); + if query_chars.len() <= initials.len() { + let mut qi = 0; + for &ic in &initials { + if qi < query_chars.len() && ic == query_chars[qi] { + qi += 1; + } + } + if qi == query_chars.len() { + return Some(50); + } + } + + // Tier 5: Description contains query + if desc.contains(query) { + return Some(40); + } + // Tier 6: Subsequence match on name (fuzzy) + let name_chars: Vec = name.chars().collect(); if query_chars.len() <= name_chars.len() { let mut qi = 0; for &nc in &name_chars { @@ -124,11 +180,11 @@ impl NodeSelectionDialog { } } if qi == query_chars.len() { - return true; + return Some(20); } } - false + None } /// Show the dialog. Returns the selected template if one was chosen. @@ -217,7 +273,7 @@ impl NodeSelectionDialog { // Handle Enter key on search input if response.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { - if let Some(&idx) = self.filtered_indices.get(self.selected_index) { + if let Some(&(idx, _)) = self.filtered_indices.get(self.selected_index) { let template = &NODE_TEMPLATES[idx]; result = Some(create_node_from_template(template, library, self.create_position)); should_close = true; @@ -247,7 +303,7 @@ impl NodeSelectionDialog { egui::Label::new( egui::RichText::new(cat).color(text_color).size(10.0), ).sense(egui::Sense::click()) - ); + ).on_hover_cursor(egui::CursorIcon::PointingHand); if response.clicked() { if cat == "All" { @@ -279,7 +335,7 @@ impl NodeSelectionDialog { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { - for (list_idx, &template_idx) in self.filtered_indices.iter().enumerate() { + for (list_idx, &(template_idx, _)) in self.filtered_indices.iter().enumerate() { let template = &NODE_TEMPLATES[template_idx]; let is_selected = list_idx == self.selected_index; @@ -368,3 +424,162 @@ impl NodeSelectionDialog { result } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_template(name: &'static str, description: &'static str) -> NodeTemplate { + NodeTemplate { + name, + prototype: "", + category: "geometry", + description, + } + } + + #[test] + fn exact_match_scores_highest() { + let dialog = NodeSelectionDialog::new(); + let t = make_template("sample", "Sample a path"); + assert_eq!(dialog.match_score(&t, "sample"), Some(100)); + } + + #[test] + fn prefix_match() { + let dialog = NodeSelectionDialog::new(); + let t = make_template("sample", "Sample a path"); + assert_eq!(dialog.match_score(&t, "sam"), Some(80)); + } + + #[test] + fn substring_match_in_name() { + let dialog = NodeSelectionDialog::new(); + let t = make_template("resample", "Resample path points"); + assert_eq!(dialog.match_score(&t, "sample"), Some(60)); + } + + #[test] + fn description_match() { + let dialog = NodeSelectionDialog::new(); + let t = make_template("ellipse", "Create an ellipse or circle"); + assert_eq!(dialog.match_score(&t, "circle"), Some(40)); + } + + #[test] + fn subsequence_match() { + let dialog = NodeSelectionDialog::new(); + let t = make_template("resample", "Resample path points"); + assert_eq!(dialog.match_score(&t, "rsl"), Some(20)); + } + + #[test] + fn no_match() { + let dialog = NodeSelectionDialog::new(); + let t = make_template("ellipse", "Create an ellipse or circle"); + assert_eq!(dialog.match_score(&t, "xyz"), None); + } + + #[test] + fn word_initial_match_scores_above_description() { + let dialog = NodeSelectionDialog::new(); + // "cr" should match word initials of convert_range (c=convert, r=range) + let t = make_template("convert_range", "Map a value from one range to another"); + let score = dialog.match_score(&t, "cr"); + assert!( + score.is_some(), + "convert_range should match 'cr' via word initials" + ); + assert!( + score.unwrap() > 40, + "word-initial match ({}) should score above description match (40)", + score.unwrap() + ); + } + + #[test] + fn word_initial_match_for_random_numbers() { + let dialog = NodeSelectionDialog::new(); + // "rn" should match word initials of random_numbers (r=random, n=numbers) + let t = make_template("random_numbers", "Generate a list of random numbers"); + let score = dialog.match_score(&t, "rn"); + assert!( + score.is_some(), + "random_numbers should match 'rn' via word initials" + ); + assert!( + score.unwrap() > 20, + "word-initial match ({}) should score above plain subsequence (20)", + score.unwrap() + ); + } + + #[test] + fn rn_ranks_random_numbers_above_translate() { + let mut dialog = NodeSelectionDialog::new(); + dialog.search_query = "rn".to_string(); + dialog.update_filtered_list(); + + let rn_pos = dialog + .filtered_indices + .iter() + .position(|&(idx, _)| NODE_TEMPLATES[idx].name == "random_numbers"); + let tr_pos = dialog + .filtered_indices + .iter() + .position(|&(idx, _)| NODE_TEMPLATES[idx].name == "translate"); + + assert!(rn_pos.is_some(), "random_numbers should be in results"); + assert!(tr_pos.is_some(), "translate should be in results"); + assert!( + rn_pos.unwrap() < tr_pos.unwrap(), + "random_numbers should appear before translate for query 'rn'" + ); + } + + #[test] + fn cr_ranks_convert_range_above_ellipse() { + let mut dialog = NodeSelectionDialog::new(); + dialog.search_query = "cr".to_string(); + dialog.update_filtered_list(); + + let cr_pos = dialog + .filtered_indices + .iter() + .position(|&(idx, _)| NODE_TEMPLATES[idx].name == "convert_range"); + let el_pos = dialog + .filtered_indices + .iter() + .position(|&(idx, _)| NODE_TEMPLATES[idx].name == "ellipse"); + + assert!(cr_pos.is_some(), "convert_range should be in results"); + assert!(el_pos.is_some(), "ellipse should be in results"); + assert!( + cr_pos.unwrap() < el_pos.unwrap(), + "convert_range should appear before ellipse for query 'cr'" + ); + } + + #[test] + fn sample_ranks_above_resample() { + let mut dialog = NodeSelectionDialog::new(); + dialog.search_query = "sample".to_string(); + dialog.update_filtered_list(); + + let sample_pos = dialog + .filtered_indices + .iter() + .position(|&(idx, _)| NODE_TEMPLATES[idx].name == "sample"); + let resample_pos = dialog + .filtered_indices + .iter() + .position(|&(idx, _)| NODE_TEMPLATES[idx].name == "resample"); + + assert!(sample_pos.is_some(), "sample should be in results"); + assert!(resample_pos.is_some(), "resample should be in results"); + assert!( + sample_pos.unwrap() < resample_pos.unwrap(), + "sample (exact) should appear before resample (substring)" + ); + } +} diff --git a/crates/nodebox-desktop/src/notification_banner.rs b/crates/nodebox-desktop/src/notification_banner.rs new file mode 100644 index 00000000..c0c58f4e --- /dev/null +++ b/crates/nodebox-desktop/src/notification_banner.rs @@ -0,0 +1,122 @@ +//! Notification banner UI component. +//! +//! Renders dismissible warning/info banners below the address bar. +//! Styled with ZINC colors for an unobtrusive appearance. + +use eframe::egui; +use crate::state::NotificationLevel; +use crate::theme; + +/// Height of a single notification banner. +pub const BANNER_HEIGHT: f32 = 28.0; + +/// Draw notification banners and return IDs of any that were dismissed. +pub fn show_notifications( + ui: &mut egui::Ui, + notifications: &[(u64, String, NotificationLevel)], +) -> Vec { + let mut dismissed = Vec::new(); + + for (id, message, _level) in notifications { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), BANNER_HEIGHT), + egui::Sense::hover(), + ); + + if !ui.is_rect_visible(rect) { + continue; + } + + // Zinc styling: unobtrusive, blends with the dark theme + let bg_color = theme::ZINC_700; + let text_color = theme::ZINC_300; + let icon_color = theme::ZINC_400; + + // Background + ui.painter().rect_filled(rect, 0.0, bg_color); + + // Subtle bottom separator + ui.painter().line_segment( + [ + egui::pos2(rect.left(), rect.bottom() - 0.5), + egui::pos2(rect.right(), rect.bottom() - 0.5), + ], + egui::Stroke::new(1.0, theme::ZINC_600), + ); + + // Warning icon: draw a small triangle with "!" using lines + let icon_x = rect.left() + theme::PADDING; + let icon_cx = icon_x + 6.0; + let icon_cy = rect.center().y; + let icon_stroke = egui::Stroke::new(1.5, icon_color); + // Triangle outline + let tri_top = egui::pos2(icon_cx, icon_cy - 5.0); + let tri_bl = egui::pos2(icon_cx - 6.0, icon_cy + 5.0); + let tri_br = egui::pos2(icon_cx + 6.0, icon_cy + 5.0); + ui.painter().line_segment([tri_top, tri_bl], icon_stroke); + ui.painter().line_segment([tri_bl, tri_br], icon_stroke); + ui.painter().line_segment([tri_br, tri_top], icon_stroke); + // Exclamation mark inside + ui.painter().line_segment( + [egui::pos2(icon_cx, icon_cy - 2.5), egui::pos2(icon_cx, icon_cy + 1.5)], + icon_stroke, + ); + ui.painter().circle_filled(egui::pos2(icon_cx, icon_cy + 3.5), 0.8, icon_color); + let icon_width = 12.0 + theme::PADDING_SMALL; + + // Message text + let text_font = egui::FontId::proportional(11.0); + let text_x = icon_x + icon_width; + let max_text_width = rect.right() - text_x - 28.0; // room for dismiss button + + let galley = ui.painter().layout( + message.clone(), + text_font, + text_color, + max_text_width, + ); + ui.painter().galley( + egui::pos2(text_x, rect.center().y - galley.size().y / 2.0), + galley, + text_color, + ); + + // Dismiss button (x) on the right + let dismiss_size = 20.0; + let dismiss_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - theme::PADDING - dismiss_size / 2.0, rect.center().y), + egui::vec2(dismiss_size, dismiss_size), + ); + + let dismiss_response = ui.interact( + dismiss_rect, + egui::Id::new(("dismiss_notification", *id)), + egui::Sense::click(), + ); + + let dismiss_color = if dismiss_response.hovered() { + theme::ZINC_200 + } else { + theme::ZINC_400 + }; + + // Draw X with two line segments (avoids missing Unicode glyph issues) + let cx = dismiss_rect.center(); + let half = 4.0; + let stroke = egui::Stroke::new(1.5, dismiss_color); + ui.painter().line_segment( + [egui::pos2(cx.x - half, cx.y - half), egui::pos2(cx.x + half, cx.y + half)], + stroke, + ); + ui.painter().line_segment( + [egui::pos2(cx.x + half, cx.y - half), egui::pos2(cx.x - half, cx.y + half)], + stroke, + ); + + if dismiss_response.clicked() { + dismissed.push(*id); + } + } + + dismissed +} diff --git a/crates/nodebox-gui/src/pan_zoom.rs b/crates/nodebox-desktop/src/pan_zoom.rs similarity index 79% rename from crates/nodebox-gui/src/pan_zoom.rs rename to crates/nodebox-desktop/src/pan_zoom.rs index f101d132..42c66687 100644 --- a/crates/nodebox-gui/src/pan_zoom.rs +++ b/crates/nodebox-desktop/src/pan_zoom.rs @@ -2,6 +2,12 @@ use eframe::egui::{self, Pos2, Rect, Vec2}; +/// Predefined zoom levels as factors (1.0 = 100%). +const ZOOM_LEVELS: &[f32] = &[ + 0.01, 0.02, 0.05, 0.08, 0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.75, 1.0, 1.5, 2.0, 3.0, + 4.0, 6.0, 8.0, 10.0, +]; + /// Pan and zoom state for a canvas view. #[derive(Clone, Debug)] pub struct PanZoom { @@ -27,7 +33,7 @@ impl PanZoom { Self { zoom: 1.0, pan: Vec2::ZERO, - min_zoom: 0.1, + min_zoom: 0.01, max_zoom: 10.0, } } @@ -51,7 +57,8 @@ impl PanZoom { /// Returns true if zoom changed. pub fn handle_scroll_zoom(&mut self, rect: Rect, ui: &egui::Ui, origin: Vec2) -> bool { if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) { - if rect.contains(mouse_pos) { + // Use layer-aware check so overlapping windows/dialogs block scroll. + if ui.rect_contains_pointer(rect) { let scroll = ui.input(|i| i.raw_scroll_delta.y); if scroll != 0.0 { let zoom_factor = 1.0 + scroll * 0.001; @@ -97,14 +104,20 @@ impl PanZoom { ((screen.to_vec2() - self.pan - origin) / self.zoom).to_pos2() } - /// Zoom in by a fixed step. + /// Zoom in to the next predefined zoom level. pub fn zoom_in(&mut self) { - self.zoom = (self.zoom * 1.25).min(self.max_zoom); + // Find the first zoom level greater than current zoom + if let Some(&level) = ZOOM_LEVELS.iter().find(|&&level| level > self.zoom) { + self.zoom = level; + } } - /// Zoom out by a fixed step. + /// Zoom out to the previous predefined zoom level. pub fn zoom_out(&mut self) { - self.zoom = (self.zoom / 1.25).max(self.min_zoom); + // Find the last zoom level less than current zoom + if let Some(&level) = ZOOM_LEVELS.iter().rev().find(|&&level| level < self.zoom) { + self.zoom = level; + } } /// Reset to default zoom and pan. diff --git a/crates/nodebox-desktop/src/parameter_panel.rs b/crates/nodebox-desktop/src/parameter_panel.rs new file mode 100644 index 00000000..f369f3f6 --- /dev/null +++ b/crates/nodebox-desktop/src/parameter_panel.rs @@ -0,0 +1,1388 @@ +//! UI panels for the NodeBox application. + +use std::sync::Arc; +use eframe::egui::{self, Sense}; +use nodebox_core::geometry::Color; +use nodebox_core::node::{PortType, Widget}; +use nodebox_core::Value; +use nodebox_core::platform::{FileFilter, Platform, PlatformError, ProjectContext}; +use crate::components; +use crate::state::AppState; +use crate::theme; + +/// The parameter editor panel with Rerun-style minimal UI. +pub struct ParameterPanel { + /// Fixed width for labels. + label_width: f32, + /// Track which port is being edited (node_name, port_name, edit_text, needs_select_all) + editing: Option<(String, String, String, bool)>, + /// Ordered list of tabbable (node_name, port_name) keys, rebuilt every frame. + tab_order: Vec<(String, String)>, + /// Deferred tab target: set when Tab/Shift+Tab is pressed, consumed next frame. + tab_target: Option<(String, String)>, + /// When true, the current edit was initiated from a label click on a Point field. + /// On commit, the value should be applied to both x and y. + label_edit_apply_both: bool, + /// Set to the committed value when a label-initiated Point edit commits. + label_edit_committed_value: Option, + /// Accumulates sub-pixel drag deltas so that default (non-Alt) drags snap to integers. + drag_accumulator: f64, + /// Whether the user is currently dragging a parameter label or drag-value widget. + is_dragging: bool, +} + +impl Default for ParameterPanel { + fn default() -> Self { + Self::new() + } +} + +impl ParameterPanel { + /// Create a new parameter panel. + /// The label_width is set to theme::LABEL_WIDTH to align with the pane header separator. + pub fn new() -> Self { + Self { + label_width: theme::LABEL_WIDTH, + editing: None, + tab_order: Vec::new(), + tab_target: None, + label_edit_apply_both: false, + label_edit_committed_value: None, + drag_accumulator: 0.0, + is_dragging: false, + } + } + + /// Whether the user is currently dragging a parameter label or drag-value widget. + pub fn is_dragging(&self) -> bool { + self.is_dragging + } + + /// Show the parameter panel. + pub fn show( + &mut self, + ui: &mut egui::Ui, + state: &mut AppState, + port: &dyn Platform, + project_context: &ProjectContext, + ) { + // Clear drag state when the primary button is released + if self.is_dragging && ui.input(|i| !i.pointer.primary_down()) { + self.is_dragging = false; + } + + // Zero spacing so header sits flush against content + ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0); + + // Validate that the selected node still exists (e.g., after undo). + // If not, clear the stale selection to avoid "not found" errors + // and a double PARAMETERS header render. + if let Some(ref name) = state.selected_node { + if state.library.root.child(name).is_none() { + state.selected_node = None; + } + } + + if let Some(ref node_name) = state.selected_node.clone() { + // First, collect connected ports while we only have immutable borrow + let connected_ports: std::collections::HashSet = state + .library + .root + .connections + .iter() + .filter(|c| c.input_node == *node_name) + .map(|c| c.input_port.clone()) + .collect(); + + // Also collect display info before mutable borrow + let (node_display_name, node_prototype) = { + if let Some(node) = state.library.root.child(&node_name) { + (Some(node.name.clone()), node.prototype.clone()) + } else { + (None, None) + } + }; + + // Build tab order from the immutable view of the node's inputs + if let Some(node) = state.library.root.child(&node_name) { + self.tab_order = Self::build_tab_order(&node_name, &node.inputs, &connected_ports); + + // Activate pending tab target + if let Some(ref target) = self.tab_target.take() { + if self.tab_order.iter().any(|k| k == target) { + let edit_text = Self::get_edit_text_for_target(&node.inputs, target); + self.editing = Some((target.0.clone(), target.1.clone(), edit_text, true)); + } + } + } + + // Show header before mutable borrow + self.show_parameters_header( + ui, + node_display_name.as_deref(), + node_prototype.as_deref(), + ); + + // Restore row spacing for content + ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 2.0); + + // Find the node in the library for mutation + if let Some(node) = Arc::make_mut(&mut state.library).root.child_mut(&node_name) { + // Clone node_name for use in closure + let node_name_clone = node_name.clone(); + + // Show input ports in a scrollable area with two-tone background + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Paint two-tone background + let full_rect = ui.max_rect(); + // Left side (labels) - darker + ui.painter().rect_filled( + egui::Rect::from_min_max( + full_rect.min, + egui::pos2(full_rect.left() + self.label_width, full_rect.max.y), + ), + 0.0, + theme::PORT_LABEL_BACKGROUND, + ); + // Right side (values) - lighter + ui.painter().rect_filled( + egui::Rect::from_min_max( + egui::pos2(full_rect.left() + self.label_width, full_rect.min.y), + full_rect.max, + ), + 0.0, + theme::PORT_VALUE_BACKGROUND, + ); + + ui.add_space(theme::PADDING); + + for node_port in &mut node.inputs { + let is_connected = connected_ports.contains(&node_port.name); + self.show_port_row( + ui, + node_port, + is_connected, + &node_name_clone, + node_prototype.as_deref(), + port, + project_context, + ); + } + }); + } else { + self.show_no_selection(ui, Some(&format!("Node '{}' not found.", node_name))); + } + } else { + // No node selected - show document properties + self.show_document_properties(ui, state); + } + } + + /// Build the tab order for the given node's inputs. + /// Returns a list of (node_name, port_key) pairs for all tabbable fields in order. + /// Point fields produce two entries: (node, "name_x") and (node, "name_y"). + fn build_tab_order( + node_name: &str, + inputs: &[nodebox_core::node::Port], + connected_ports: &std::collections::HashSet, + ) -> Vec<(String, String)> { + let mut order = Vec::new(); + for port in inputs { + if connected_ports.contains(&port.name) { + continue; + } + match port.widget { + Widget::Float | Widget::Angle | Widget::Int | Widget::String | Widget::Text => { + order.push((node_name.to_string(), port.name.clone())); + } + Widget::Point => { + order.push((node_name.to_string(), format!("{}_x", port.name))); + order.push((node_name.to_string(), format!("{}_y", port.name))); + } + _ => {} + } + } + order + } + + /// Find the next or previous tab stop in the tab order, wrapping around. + fn next_tab_stop( + tab_order: &[(String, String)], + current: &(String, String), + forward: bool, + ) -> Option<(String, String)> { + if tab_order.is_empty() { + return None; + } + let pos = tab_order.iter().position(|k| k == current); + let next_idx = match pos { + Some(idx) => { + if forward { + (idx + 1) % tab_order.len() + } else if idx == 0 { + tab_order.len() - 1 + } else { + idx - 1 + } + } + None => 0, + }; + Some(tab_order[next_idx].clone()) + } + + /// Get the text representation of a port value for pre-filling the edit field. + fn get_edit_text_for_target( + inputs: &[nodebox_core::node::Port], + target: &(String, String), + ) -> String { + // Try exact port name match first + if let Some(port) = inputs.iter().find(|p| p.name == target.1) { + return match &port.value { + Value::Float(v) => format!("{:.2}", v), + Value::Int(v) => format!("{}", v), + Value::String(v) => v.clone(), + _ => String::new(), + }; + } + // Try Point suffix: target.1 ends with "_x" or "_y" + if let Some(base) = target.1.strip_suffix("_x") { + if let Some(port) = inputs.iter().find(|p| p.name == base) { + if let Value::Point(ref pt) = port.value { + return format!("{:.2}", pt.x); + } + } + } + if let Some(base) = target.1.strip_suffix("_y") { + if let Some(port) = inputs.iter().find(|p| p.name == base) { + if let Value::Point(ref pt) = port.value { + return format!("{:.2}", pt.y); + } + } + } + String::new() + } + + /// Detect whether a TextEdit lost focus due to Tab/Shift+Tab navigation. + /// + /// On some platforms (e.g. Linux/X11), Shift+Tab generates `ISO_Left_Tab` + /// instead of `Key::Tab`, so scanning for `Key::Tab` events is unreliable. + /// Instead, we infer Tab navigation by exclusion: if focus was lost and it + /// wasn't from Escape, Enter, or a mouse click, it must be Tab/Shift+Tab. + /// + /// Call BEFORE `TextEdit::show()` to capture `Enter` state before the + /// TextEdit potentially consumes the event. + fn detect_enter_pressed(ui: &egui::Ui) -> bool { + ui.input(|i| i.key_pressed(egui::Key::Enter)) + } + + /// Compute the drag speed modifier based on keyboard modifiers. + /// Shift = 10x (coarse), Alt = 0.01x (fine), otherwise 1x. + fn drag_modifier(ui: &egui::Ui) -> f64 { + ui.input(|i| { + if i.modifiers.shift { + 10.0 + } else if i.modifiers.alt { + 0.01 + } else { + 1.0 + } + }) + } + + /// Draw a right-aligned label in a fixed-width column, optionally with drag-to-adjust + /// and click-to-edit interaction. + /// + /// Returns (drag_delta_pixels, drag_started). + fn show_draggable_label( + &mut self, + ui: &mut egui::Ui, + label: &str, + click_edit_state: Option<(String, String, String)>, + set_apply_both: bool, + ) -> (f32, bool) { + let label_width = self.label_width; + let mut drag_delta_x: f32 = 0.0; + let mut drag_started = false; + let label_owned = label.to_string(); + + ui.allocate_ui_with_layout( + egui::Vec2::new(label_width, theme::PARAMETER_ROW_HEIGHT), + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.add_space(8.0); + let bg_idx = ui.painter().add(egui::Shape::Noop); + let galley = ui.painter().layout_no_wrap( + label_owned.clone(), + egui::FontId::proportional(11.0), + theme::TEXT_NORMAL, + ); + let rect = ui.available_rect_before_wrap(); + let pos = egui::pos2( + rect.right() - galley.size().x - 8.0, + rect.center().y - galley.size().y / 2.0, + ); + ui.painter().galley(pos, galley, theme::TEXT_NORMAL); + + if let Some(ref edit_state) = click_edit_state { + let full_rect = ui.max_rect(); + let interact_id = ui.id().with(("label_drag", &label_owned)); + let response = ui.interact(full_rect, interact_id, Sense::click_and_drag()); + if response.hovered() || response.dragged() { + ui.painter().set(bg_idx, egui::Shape::rect_filled( + full_rect, 0.0, theme::FIELD_HOVER_BG, + )); + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if response.drag_started() { + drag_started = true; + self.is_dragging = true; + } + if response.dragged() { + drag_delta_x = response.drag_delta().x; + } + if response.clicked() { + self.editing = Some((edit_state.0.clone(), edit_state.1.clone(), edit_state.2.clone(), true)); + if set_apply_both { + self.label_edit_apply_both = true; + } + } + } + }, + ); + + (drag_delta_x, drag_started) + } + + /// Show a single port row with label and value editor. + fn show_port_row( + &mut self, + ui: &mut egui::Ui, + port: &mut nodebox_core::node::Port, + is_connected: bool, + node_name: &str, + node_prototype: Option<&str>, + io_port: &dyn Platform, + project_context: &ProjectContext, + ) { + let is_label_draggable = !is_connected + && matches!(port.widget, Widget::Float | Widget::Angle | Widget::Int | Widget::Point); + let port_name = port.name.clone(); + let mut label_drag_delta_x: f32 = 0.0; + let mut label_drag_started = false; + + // Pre-compute the editing state for label click (avoids borrowing port in the closure) + let label_click_edit_state: Option<(String, String, String)> = if is_label_draggable { + match (&port.widget, &port.value) { + (Widget::Float | Widget::Angle, Value::Float(v)) => { + Some((node_name.to_string(), port.name.clone(), format!("{:.2}", v))) + } + (Widget::Int, Value::Int(v)) => { + Some((node_name.to_string(), port.name.clone(), format!("{}", v))) + } + (Widget::Point, Value::Point(p)) => { + Some((node_name.to_string(), format!("{}_x", port.name), format!("{:.2}", p.x))) + } + _ => None, + } + } else { + None + }; + let label_click_is_point = is_label_draggable && matches!(port.widget, Widget::Point); + + ui.horizontal(|ui| { + ui.set_height(theme::PARAMETER_ROW_HEIGHT); + + // Fixed-width label, right-aligned (non-selectable) + (label_drag_delta_x, label_drag_started) = self.show_draggable_label( + ui, &port_name, label_click_edit_state, label_click_is_point, + ); + + // Value editor + if is_connected { + // Non-selectable "connected" text + let galley = ui.painter().layout_no_wrap( + "connected".to_string(), + egui::FontId::proportional(11.0), + theme::TEXT_DISABLED, + ); + let rect = ui.available_rect_before_wrap(); + let pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); + ui.painter().galley(pos, galley, theme::TEXT_DISABLED); + } else { + self.show_port_editor(ui, port, node_name, node_prototype, io_port, project_context); + + // Apply label-initiated Point edit to both x and y + if let Some(committed_val) = self.label_edit_committed_value.take() { + if let Value::Point(ref mut point) = port.value { + point.y = committed_val; + } + self.label_edit_apply_both = false; + } else if self.label_edit_apply_both && self.editing.is_none() { + // Edit was cancelled, clear the flag + self.label_edit_apply_both = false; + } + } + }); + + // Apply label drag delta to port value + if label_drag_started { + self.drag_accumulator = 0.0; + } + if label_drag_delta_x != 0.0 { + let modifier = Self::drag_modifier(ui); + self.drag_accumulator += label_drag_delta_x as f64 * modifier; + + let apply_delta = if ui.input(|i| i.modifiers.alt) { + // Fine mode: apply full fractional delta + let d = self.drag_accumulator; + self.drag_accumulator = 0.0; + d + } else { + // Integer mode: only apply integer portion + let int_delta = self.drag_accumulator.trunc(); + self.drag_accumulator -= int_delta; + int_delta + }; + + if apply_delta != 0.0 { + match port.widget { + Widget::Float | Widget::Angle => { + if let Value::Float(ref mut value) = port.value { + *value += apply_delta; + if let Some(min_val) = port.min { + *value = value.max(min_val); + } + if let Some(max_val) = port.max { + *value = value.min(max_val); + } + } + } + Widget::Int => { + if let Value::Int(ref mut value) = port.value { + *value += apply_delta as i64; + } + } + Widget::Point => { + if let Value::Point(ref mut point) = port.value { + point.x += apply_delta; + point.y += apply_delta; + } + } + _ => {} + } + } + } + } + + /// Show the editor widget for a port value - minimal style with no borders. + fn show_port_editor( + &mut self, + ui: &mut egui::Ui, + port: &mut nodebox_core::node::Port, + node_name: &str, + node_prototype: Option<&str>, + io_port: &dyn Platform, + project_context: &ProjectContext, + ) { + let port_key = (node_name.to_string(), port.name.clone()); + + // Check if we're editing this port + let is_editing = self.editing.as_ref() + .map(|(n, p, _, _)| n == node_name && p == &port.name) + .unwrap_or(false); + + match port.widget { + Widget::Float | Widget::Angle => { + if let Value::Float(ref mut value) = port.value { + self.show_drag_value_float(ui, value, port.min, port.max, 1.0, &port_key, is_editing, theme::PADDING); + } + } + Widget::Int => { + if let Value::Int(ref mut value) = port.value { + self.show_drag_value_int(ui, value, port.min, port.max, &port_key, is_editing, theme::PADDING); + } + } + Widget::Toggle => { + if let Value::Boolean(ref mut value) = port.value { + // Non-selectable clickable boolean + let text = if *value { "true" } else { "false" }; + let galley = ui.painter().layout_no_wrap( + text.to_string(), + egui::FontId::proportional(11.0), + theme::VALUE_TEXT, + ); + let rect = ui.available_rect_before_wrap(); + let text_rect = egui::Rect::from_min_size( + egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0), + galley.size(), + ); + + let response = ui.allocate_rect(text_rect, Sense::click()); + ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); + + if response.clicked() { + *value = !*value; + } + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + } + Widget::String | Widget::Text => { + if let Value::String(ref mut value) = port.value { + if is_editing { + // Show text input for direct editing + let (mut edit_text, needs_select) = self.editing.as_ref() + .map(|(_, _, t, sel)| (t.clone(), *sel)) + .unwrap_or_else(|| (value.clone(), true)); + + // Capture Enter state before TextEdit may consume it. + let enter_pressed = Self::detect_enter_pressed(ui); + + // Frameless TextEdit with manual background for pixel-perfect alignment + let old_selection = ui.visuals().selection.clone(); + ui.visuals_mut().selection.stroke = egui::Stroke::new(0.0, egui::Color32::WHITE); + ui.visuals_mut().selection.bg_fill = theme::TEXT_EDIT_SELECTION_BG; + + let bg_idx = ui.painter().add(egui::Shape::Noop); + let output = egui::TextEdit::singleline(&mut edit_text) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .desired_width(ui.available_width() - theme::PADDING - theme::PADDING) + .margin(egui::Margin::symmetric(4, 0)) + .frame(false) + .show(ui); + + // Paint rounded background behind the text + let bg_rect = output.response.rect.expand2(egui::vec2(0.0, 4.0)); + ui.painter().set(bg_idx, egui::Shape::rect_filled( + bg_rect, + egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8), + theme::ZINC_700, + )); + + ui.visuals_mut().selection = old_selection; + + // Select all on first frame + if needs_select { + if let Some((_, _, _, ref mut sel)) = self.editing { + *sel = false; + } + let text_len = edit_text.chars().count(); + let mut state = output.state.clone(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(text_len), + ))); + state.store(ui.ctx(), output.response.id); + } + + // Update edit text + if let Some((_, _, ref mut t, _)) = self.editing { + *t = edit_text.clone(); + } + + // Commit on enter or focus lost + if output.response.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + self.editing = None; + } else { + *value = edit_text; + self.editing = None; + // If focus was lost from keyboard Tab navigation + // (not Enter, not mouse click), advance to next/prev field. + let mouse_clicked = ui.input(|i| i.pointer.any_pressed()); + if !enter_pressed && !mouse_clicked { + let forward = !ui.input(|i| i.modifiers.shift); + self.tab_target = Self::next_tab_stop(&self.tab_order, &port_key, forward); + } + } + } + + // Request focus on first frame + if self.editing.is_some() { + output.response.request_focus(); + } + } else { + // Non-interactive TextEdit for pixel-perfect alignment with editing state + let display = if value.is_empty() { "\"\"".to_string() } else { value.clone() }; + let mut display_text = display; + let bg_idx = ui.painter().add(egui::Shape::Noop); + let te_output = egui::TextEdit::singleline(&mut display_text) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .interactive(false) + .frame(false) + .margin(egui::Margin::symmetric(4, 0)) + .desired_width(ui.available_width() - theme::PADDING - theme::PADDING) + .show(ui); + + // Overlay click sensing on the same rect + let interact_id = ui.id().with(&port_key); + let response = ui.interact(te_output.response.rect, interact_id, Sense::click()); + + // Hover effect: subtle darkened background + if response.hovered() { + let hover_rect = te_output.response.rect.expand2(egui::vec2(0.0, 4.0)); + ui.painter().set(bg_idx, egui::Shape::rect_filled( + hover_rect, + egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8), + theme::FIELD_HOVER_BG, + )); + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + + // Click to edit + if response.clicked() { + self.editing = Some((port_key.0, port_key.1, value.clone(), true)); + } + } + } + } + Widget::Color => { + if let Value::Color(ref mut color) = port.value { + let mut rgba = [ + (color.r * 255.0) as u8, + (color.g * 255.0) as u8, + (color.b * 255.0) as u8, + (color.a * 255.0) as u8, + ]; + if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() { + color.r = rgba[0] as f64 / 255.0; + color.g = rgba[1] as f64 / 255.0; + color.b = rgba[2] as f64 / 255.0; + color.a = rgba[3] as f64 / 255.0; + } + } + } + Widget::Point => { + if let Value::Point(ref mut point) = port.value { + let key_x = (port_key.0.clone(), format!("{}_x", port_key.1)); + let key_y = (port_key.0.clone(), format!("{}_y", port_key.1)); + let is_editing_x = self.editing.as_ref() + .map(|(n, p, _, _)| n == &key_x.0 && p == &key_x.1) + .unwrap_or(false); + let is_editing_y = self.editing.as_ref() + .map(|(n, p, _, _)| n == &key_y.0 && p == &key_y.1) + .unwrap_or(false); + let available = ui.available_width() - theme::PADDING; + let old_spacing = ui.spacing().item_spacing.x; + ui.spacing_mut().item_spacing.x = 16.0; + let field_width = (available - 16.0) / 2.0; + ui.allocate_ui(egui::Vec2::new(field_width, theme::PARAMETER_ROW_HEIGHT), |ui| { + self.show_drag_value_float(ui, &mut point.x, None, None, 1.0, &key_x, is_editing_x, 0.0); + }); + ui.allocate_ui(egui::Vec2::new(field_width, theme::PARAMETER_ROW_HEIGHT), |ui| { + self.show_drag_value_float(ui, &mut point.y, None, None, 1.0, &key_y, is_editing_y, 0.0); + }); + ui.spacing_mut().item_spacing.x = old_spacing; + } + } + Widget::Menu => { + if let Value::String(ref mut value) = port.value { + // Find current label from menu_items + let current_label = port.menu_items.iter() + .find(|item| item.key == *value) + .map(|item| item.label.as_str()) + .unwrap_or(value.as_str()); + + // Use smaller font to match other controls + let style = ui.style_mut(); + style.override_font_id = Some(egui::FontId::proportional(theme::FONT_SIZE_SMALL)); + + // Use egui ComboBox + let combo_id = ui.make_persistent_id((&port_key.0, &port_key.1)); + egui::ComboBox::from_id_salt(combo_id) + .selected_text(current_label) + .width(120.0) + .show_ui(ui, |ui| { + for item in &port.menu_items { + if ui.selectable_label(*value == item.key, &item.label).clicked() { + *value = item.key.clone(); + } + } + }); + } + } + Widget::File => { + if let Value::String(ref mut path) = port.value { + // Show filename or placeholder, styled like the string widget + let display_text = if path.is_empty() { + "(none)".to_string() + } else { + // Extract just the filename from the path + std::path::Path::new(path.as_str()) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()) + }; + + // Use non-interactive TextEdit for pixel-perfect alignment with string widget + let mut display = display_text.clone(); + let bg_idx = ui.painter().add(egui::Shape::Noop); + let available_w = ui.available_width() - theme::PADDING - theme::PADDING; + + // Reserve space for the "..." button on the right + let dots_width = 20.0; + + let te_output = egui::TextEdit::singleline(&mut display) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(if path.is_empty() { theme::TEXT_DISABLED } else { egui::Color32::WHITE }) + .interactive(false) + .frame(false) + .margin(egui::Margin::symmetric(4, 0)) + .desired_width(available_w - dots_width) + .show(ui); + + // Draw "..." button right-aligned in the remaining space + let row_rect = te_output.response.rect; + let dots_rect = egui::Rect::from_min_size( + egui::pos2(row_rect.right(), row_rect.top()), + egui::vec2(dots_width, row_rect.height()), + ); + let dots_color = theme::TEXT_SUBDUED; + ui.painter().text( + dots_rect.center(), + egui::Align2::CENTER_CENTER, + "...", + egui::FontId::proportional(theme::FONT_SIZE_SMALL), + dots_color, + ); + + // Overlay click sensing on the full row (text + dots) + let full_rect = egui::Rect::from_min_max(row_rect.min, dots_rect.max); + let interact_id = ui.id().with(&port_key); + let response = ui.interact(full_rect, interact_id, Sense::click()); + + // Hover effect: subtle darkened background (same as string widget) + if response.hovered() { + let hover_rect = full_rect.expand2(egui::vec2(0.0, 4.0)); + ui.painter().set(bg_idx, egui::Shape::rect_filled( + hover_rect, + egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8), + theme::FIELD_HOVER_BG, + )); + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Click to open file picker + if response.clicked() { + // Check if project is saved first + if !project_context.is_saved() { + let _ = io_port.show_message_dialog( + "Save Project First", + "Please save your project before importing files.", + &["OK"], + ); + } else { + // Choose file filters based on the node type + let filters = match node_prototype { + Some("data.import_csv") => vec![FileFilter::csv()], + Some("data.import_text") => vec![FileFilter::text()], + Some("corevector.import_svg") => vec![FileFilter::svg()], + _ => vec![], // No filter = all files + }; + match io_port.show_open_file_dialog( + project_context, + &filters, + ) { + Ok(Some(relative_path)) => { + *path = relative_path.to_string(); + } + Ok(None) => {} // User cancelled + Err(PlatformError::SandboxViolation) => { + let _ = io_port.show_message_dialog( + "File Outside Project", + "Please copy the file to your project folder first.", + &["OK"], + ); + } + Err(e) => { + log::error!("File dialog error: {}", e); + } + } + } + } + } + } + Widget::Font => { + if let Value::String(ref mut value) = port.value { + let style = ui.style_mut(); + style.override_font_id = Some(egui::FontId::proportional(theme::FONT_SIZE_SMALL)); + + let combo_id = ui.make_persistent_id((&port_key.0, &port_key.1)); + egui::ComboBox::from_id_salt(combo_id) + .selected_text(value.as_str()) + .width(120.0) + .show_ui(ui, |ui| { + for family in io_port.list_fonts() { + if ui.selectable_label(*value == family, &family).clicked() { + *value = family; + } + } + }); + } + } + _ => { + // For geometry and other non-editable types, show type info (non-selectable) + let type_str = match port.port_type { + PortType::Geometry => "Geometry", + _ => port.port_type.as_str(), + }; + let galley = ui.painter().layout_no_wrap( + type_str.to_string(), + egui::FontId::proportional(11.0), + theme::TEXT_DISABLED, + ); + let rect = ui.available_rect_before_wrap(); + let pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); + ui.painter().galley(pos, galley, theme::TEXT_DISABLED); + } + } + } + + /// Show a minimal drag value for floats - non-selectable, draggable, click to edit. + /// `right_padding` is extra space to reserve on the right (e.g. PADDING for panel edge margin, 0.0 for Point widget fields). + fn show_drag_value_float( + &mut self, + ui: &mut egui::Ui, + value: &mut f64, + min: Option, + max: Option, + speed: f64, + port_key: &(String, String), + is_editing: bool, + right_padding: f32, + ) { + if is_editing { + // Show text input for direct editing + let (mut edit_text, needs_select) = self.editing.as_ref() + .map(|(_, _, t, sel)| (t.clone(), *sel)) + .unwrap_or_else(|| (format!("{:.2}", value), true)); + + // Capture Enter state before TextEdit may consume it. + let enter_pressed = Self::detect_enter_pressed(ui); + + // Frameless TextEdit with manual background for pixel-perfect alignment + let old_selection = ui.visuals().selection.clone(); + ui.visuals_mut().selection.stroke = egui::Stroke::new(0.0, egui::Color32::WHITE); + ui.visuals_mut().selection.bg_fill = theme::TEXT_EDIT_SELECTION_BG; + + let bg_idx = ui.painter().add(egui::Shape::Noop); + let output = egui::TextEdit::singleline(&mut edit_text) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .desired_width(ui.available_width() - theme::PADDING - right_padding) + .margin(egui::Margin::symmetric(4, 0)) + .frame(false) + .show(ui); + + // Paint rounded background behind the text + let bg_rect = output.response.rect.expand2(egui::vec2(0.0, 4.0)); + ui.painter().set(bg_idx, egui::Shape::rect_filled( + bg_rect, + egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8), + theme::ZINC_700, + )); + + ui.visuals_mut().selection = old_selection; + + // Select all on first frame + if needs_select { + if let Some((_, _, _, ref mut sel)) = self.editing { + *sel = false; + } + let text_len = edit_text.chars().count(); + let mut state = output.state.clone(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(text_len), + ))); + state.store(ui.ctx(), output.response.id); + } + + // Update edit text + if let Some((_, _, ref mut t, _)) = self.editing { + *t = edit_text.clone(); + } + + // Commit on enter or focus lost + if output.response.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + self.editing = None; + } else { + if let Ok(new_val) = edit_text.parse::() { + let mut clamped = new_val; + if let Some(min_val) = min { + clamped = clamped.max(min_val); + } + if let Some(max_val) = max { + clamped = clamped.min(max_val); + } + *value = clamped; + if self.label_edit_apply_both { + self.label_edit_committed_value = Some(clamped); + } + } + self.editing = None; + // If focus was lost from keyboard Tab navigation + // (not Enter, not mouse click), advance to next/prev field. + let mouse_clicked = ui.input(|i| i.pointer.any_pressed()); + if !enter_pressed && !mouse_clicked { + let forward = !ui.input(|i| i.modifiers.shift); + self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); + } + } + } + + if self.editing.is_some() { + output.response.request_focus(); + } + } else { + // Non-interactive TextEdit for pixel-perfect alignment with editing state + let mut display_text = format!("{:.2}", value); + let bg_idx = ui.painter().add(egui::Shape::Noop); + let te_output = egui::TextEdit::singleline(&mut display_text) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .interactive(false) + .frame(false) + .margin(egui::Margin::symmetric(4, 0)) + .desired_width(ui.available_width() - theme::PADDING - right_padding) + .show(ui); + + // Overlay click+drag sensing on the same rect + let interact_id = ui.id().with(port_key); + let response = ui.interact(te_output.response.rect, interact_id, Sense::click_and_drag()); + + // Hover effect: subtle darkened background + if response.hovered() || response.dragged() { + let hover_rect = te_output.response.rect.expand2(egui::vec2(0.0, 4.0)); + ui.painter().set(bg_idx, egui::Shape::rect_filled( + hover_rect, + egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8), + theme::FIELD_HOVER_BG, + )); + } + + if response.drag_started() { + self.drag_accumulator = 0.0; + self.is_dragging = true; + } + if response.dragged() { + let modifier = Self::drag_modifier(ui); + self.drag_accumulator += response.drag_delta().x as f64 * speed * modifier; + + let apply_delta = if ui.input(|i| i.modifiers.alt) { + // Fine mode: apply full fractional delta + let d = self.drag_accumulator; + self.drag_accumulator = 0.0; + d + } else { + // Integer mode: only apply integer portion + let int_delta = self.drag_accumulator.trunc(); + self.drag_accumulator -= int_delta; + int_delta + }; + + if apply_delta != 0.0 { + *value += apply_delta; + } + if let Some(min_val) = min { + *value = value.max(min_val); + } + if let Some(max_val) = max { + *value = value.min(max_val); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Click to edit + if response.clicked() { + self.editing = Some((port_key.0.clone(), port_key.1.clone(), format!("{:.2}", value), true)); + } + } + } + + /// Show a minimal drag value for ints - non-selectable, draggable, click to edit. + fn show_drag_value_int(&mut self, ui: &mut egui::Ui, value: &mut i64, min: Option, max: Option, port_key: &(String, String), is_editing: bool, right_padding: f32) { + if is_editing { + // Show text input for direct editing + let (mut edit_text, needs_select) = self.editing.as_ref() + .map(|(_, _, t, sel)| (t.clone(), *sel)) + .unwrap_or_else(|| (format!("{}", value), true)); + + // Capture Enter state before TextEdit may consume it. + let enter_pressed = Self::detect_enter_pressed(ui); + + // Frameless TextEdit with manual background for pixel-perfect alignment + let old_selection = ui.visuals().selection.clone(); + ui.visuals_mut().selection.stroke = egui::Stroke::new(0.0, egui::Color32::WHITE); + ui.visuals_mut().selection.bg_fill = theme::TEXT_EDIT_SELECTION_BG; + + let bg_idx = ui.painter().add(egui::Shape::Noop); + let output = egui::TextEdit::singleline(&mut edit_text) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .desired_width(ui.available_width() - theme::PADDING - right_padding) + .margin(egui::Margin::symmetric(4, 0)) + .frame(false) + .show(ui); + + // Paint rounded background behind the text + let bg_rect = output.response.rect.expand2(egui::vec2(0.0, 4.0)); + ui.painter().set(bg_idx, egui::Shape::rect_filled( + bg_rect, + egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8), + theme::ZINC_700, + )); + + ui.visuals_mut().selection = old_selection; + + // Select all on first frame + if needs_select { + if let Some((_, _, _, ref mut sel)) = self.editing { + *sel = false; + } + let text_len = edit_text.chars().count(); + let mut state = output.state.clone(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(text_len), + ))); + state.store(ui.ctx(), output.response.id); + } + + if let Some((_, _, ref mut t, _)) = self.editing { + *t = edit_text.clone(); + } + + if output.response.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + self.editing = None; + } else { + if let Ok(new_val) = edit_text.parse::() { + let mut clamped = new_val; + if let Some(min_val) = min { + clamped = clamped.max(min_val as i64); + } + if let Some(max_val) = max { + clamped = clamped.min(max_val as i64); + } + *value = clamped; + } + self.editing = None; + // If focus was lost from keyboard Tab navigation + // (not Enter, not mouse click), advance to next/prev field. + let mouse_clicked = ui.input(|i| i.pointer.any_pressed()); + if !enter_pressed && !mouse_clicked { + let forward = !ui.input(|i| i.modifiers.shift); + self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); + } + } + } + + if self.editing.is_some() { + output.response.request_focus(); + } + } else { + // Non-interactive TextEdit for pixel-perfect alignment with editing state + let mut display_text = format!("{}", value); + let bg_idx = ui.painter().add(egui::Shape::Noop); + let te_output = egui::TextEdit::singleline(&mut display_text) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .interactive(false) + .frame(false) + .margin(egui::Margin::symmetric(4, 0)) + .desired_width(ui.available_width() - theme::PADDING - right_padding) + .show(ui); + + // Overlay click+drag sensing on the same rect + let interact_id = ui.id().with(port_key); + let response = ui.interact(te_output.response.rect, interact_id, Sense::click_and_drag()); + + // Hover effect: subtle darkened background + if response.hovered() || response.dragged() { + let hover_rect = te_output.response.rect.expand2(egui::vec2(0.0, 4.0)); + ui.painter().set(bg_idx, egui::Shape::rect_filled( + hover_rect, + egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8), + theme::FIELD_HOVER_BG, + )); + } + + if response.drag_started() { + self.drag_accumulator = 0.0; + self.is_dragging = true; + } + if response.dragged() { + let modifier = Self::drag_modifier(ui); + self.drag_accumulator += response.drag_delta().x as f64 * modifier; + let int_delta = self.drag_accumulator.trunc() as i64; + if int_delta != 0 { + *value += int_delta; + self.drag_accumulator -= int_delta as f64; + } + if let Some(min_val) = min { + *value = (*value).max(min_val as i64); + } + if let Some(max_val) = max { + *value = (*value).min(max_val as i64); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + if response.clicked() { + self.editing = Some((port_key.0.clone(), port_key.1.clone(), format!("{}", value), true)); + } + } + } + + /// Show document properties when no node is selected. + fn show_no_selection(&mut self, ui: &mut egui::Ui, error: Option<&str>) { + if let Some(err) = error { + // Show header even for errors + self.show_parameters_header(ui, None, None); + ui.vertical_centered(|ui| { + ui.add_space(30.0); + ui.label( + egui::RichText::new(err) + .color(theme::ERROR_RED) + .size(12.0), + ); + }); + } else { + // Show merged header with "Document" + self.show_parameters_header(ui, Some("Document"), None); + + // Hint text + ui.vertical_centered(|ui| { + ui.add_space(theme::PADDING); + ui.label( + egui::RichText::new("Select a node to edit parameters") + .color(theme::TEXT_DISABLED) + .size(11.0), + ); + }); + } + } + + /// Show the merged parameters header: PARAMETERS | node_name ... prototype + fn show_parameters_header(&self, ui: &mut egui::Ui, node_name: Option<&str>, prototype: Option<&str>) { + let (header_rect, x) = components::draw_pane_header_with_title(ui, "Parameters"); + + // Only show node info if we have a node name + if let Some(name) = node_name { + // Node name after separator + ui.painter().text( + egui::pos2(x, header_rect.center().y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(10.0), + theme::TEXT_BRIGHT, + ); + + // Prototype on right + if let Some(proto) = prototype { + ui.painter().text( + header_rect.right_center() - egui::vec2(theme::PADDING, 0.0), + egui::Align2::RIGHT_CENTER, + proto, + egui::FontId::proportional(10.0), + theme::TEXT_DISABLED, + ); + } + } + } + + /// Show document properties panel (canvas size, etc.). + pub fn show_document_properties(&mut self, ui: &mut egui::Ui, state: &mut AppState) { + // Build tab order for document properties + self.tab_order = vec![ + ("__document__".to_string(), "width".to_string()), + ("__document__".to_string(), "height".to_string()), + ]; + + // Activate pending tab target + if let Some(ref target) = self.tab_target.take() { + if self.tab_order.iter().any(|k| k == target) { + let edit_text = if target.1 == "width" { + format!("{:.2}", state.library.width()) + } else { + format!("{:.2}", state.library.height()) + }; + self.editing = Some((target.0.clone(), target.1.clone(), edit_text, true)); + } + } + + + // Merged header with "Document" + self.show_parameters_header(ui, Some("Document"), None); + + // Restore row spacing for content + ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 2.0); + + // Paint two-tone background for the content area + let content_rect = ui.available_rect_before_wrap(); + // Left side (labels) - darker + ui.painter().rect_filled( + egui::Rect::from_min_max( + content_rect.min, + egui::pos2(content_rect.left() + self.label_width, content_rect.max.y), + ), + 0.0, + theme::PORT_LABEL_BACKGROUND, + ); + // Right side (values) - lighter + ui.painter().rect_filled( + egui::Rect::from_min_max( + egui::pos2(content_rect.left() + self.label_width, content_rect.min.y), + content_rect.max, + ), + 0.0, + theme::PORT_VALUE_BACKGROUND, + ); + + ui.add_space(theme::PADDING); + + // Width + let current_width = state.library.width(); + let mut width_label_drag: f32 = 0.0; + let mut width_drag_started = false; + ui.horizontal(|ui| { + ui.set_height(theme::PARAMETER_ROW_HEIGHT); + + (width_label_drag, width_drag_started) = self.show_draggable_label( + ui, "width", + Some(("__document__".to_string(), "width".to_string(), format!("{:.2}", current_width))), + false, + ); + + // Value + let mut width = current_width; + let key = ("__document__".to_string(), "width".to_string()); + let is_editing = self.editing.as_ref() + .map(|(n, p, _, _)| n == &key.0 && p == &key.1) + .unwrap_or(false); + self.show_drag_value_float(ui, &mut width, Some(1.0), None, 1.0, &key, is_editing, theme::PADDING); + + // Update the property if changed + if (current_width - width).abs() > 0.001 { + Arc::make_mut(&mut state.library).set_width(width); + } + }); + if width_drag_started { + self.drag_accumulator = 0.0; + } + if width_label_drag != 0.0 { + let modifier = Self::drag_modifier(ui); + self.drag_accumulator += width_label_drag as f64 * modifier; + + let apply_delta = if ui.input(|i| i.modifiers.alt) { + let d = self.drag_accumulator; + self.drag_accumulator = 0.0; + d + } else { + let int_delta = self.drag_accumulator.trunc(); + self.drag_accumulator -= int_delta; + int_delta + }; + + if apply_delta != 0.0 { + let new_width = (state.library.width() + apply_delta).max(1.0); + Arc::make_mut(&mut state.library).set_width(new_width); + } + } + + // Height + let current_height = state.library.height(); + let mut height_label_drag: f32 = 0.0; + let mut height_drag_started = false; + ui.horizontal(|ui| { + ui.set_height(theme::PARAMETER_ROW_HEIGHT); + + (height_label_drag, height_drag_started) = self.show_draggable_label( + ui, "height", + Some(("__document__".to_string(), "height".to_string(), format!("{:.2}", current_height))), + false, + ); + + // Value + let mut height = current_height; + let key = ("__document__".to_string(), "height".to_string()); + let is_editing = self.editing.as_ref() + .map(|(n, p, _, _)| n == &key.0 && p == &key.1) + .unwrap_or(false); + self.show_drag_value_float(ui, &mut height, Some(1.0), None, 1.0, &key, is_editing, theme::PADDING); + + // Update the property if changed + if (current_height - height).abs() > 0.001 { + Arc::make_mut(&mut state.library).set_height(height); + } + }); + if height_drag_started { + self.drag_accumulator = 0.0; + } + if height_label_drag != 0.0 { + let modifier = Self::drag_modifier(ui); + self.drag_accumulator += height_label_drag as f64 * modifier; + + let apply_delta = if ui.input(|i| i.modifiers.alt) { + let d = self.drag_accumulator; + self.drag_accumulator = 0.0; + d + } else { + let int_delta = self.drag_accumulator.trunc(); + self.drag_accumulator -= int_delta; + int_delta + }; + + if apply_delta != 0.0 { + let new_height = (state.library.height() + apply_delta).max(1.0); + Arc::make_mut(&mut state.library).set_height(new_height); + } + } + + // Background color + ui.horizontal(|ui| { + ui.set_height(theme::PARAMETER_ROW_HEIGHT); + + self.show_draggable_label(ui, "background", None, false); + + // Color widget + let color = state.background_color; + let mut rgba = [ + (color.r * 255.0) as u8, + (color.g * 255.0) as u8, + (color.b * 255.0) as u8, + (color.a * 255.0) as u8, + ]; + ui.color_edit_button_srgba_unmultiplied(&mut rgba); + let new_color = Color::rgba( + rgba[0] as f64 / 255.0, + rgba[1] as f64 / 255.0, + rgba[2] as f64 / 255.0, + rgba[3] as f64 / 255.0, + ); + if new_color != color { + state.background_color = new_color; + Arc::make_mut(&mut state.library).set_background_color(new_color); + } + }); + } +} diff --git a/crates/nodebox-desktop/src/recent_files.rs b/crates/nodebox-desktop/src/recent_files.rs new file mode 100644 index 00000000..304a9a5b --- /dev/null +++ b/crates/nodebox-desktop/src/recent_files.rs @@ -0,0 +1,199 @@ +//! Recent files management for the "Open Recent" menu functionality. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Maximum number of recent files to store. +const MAX_RECENT_FILES: usize = 10; + +/// Recent files storage filename. +const RECENT_FILES_FILENAME: &str = "recent_files.json"; + +/// Manages a list of recently opened files. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentFiles { + files: Vec, +} + +impl Default for RecentFiles { + fn default() -> Self { + Self::new() + } +} + +impl RecentFiles { + /// Create a new empty recent files list. + pub fn new() -> Self { + Self { files: Vec::new() } + } + + /// Load recent files from disk, filtering out non-existent files. + pub fn load() -> Self { + let Some(path) = Self::config_path() else { + return Self::new(); + }; + + let Ok(contents) = std::fs::read_to_string(&path) else { + return Self::new(); + }; + + let Ok(mut recent) = serde_json::from_str::(&contents) else { + return Self::new(); + }; + + // Filter out non-existent files + recent.files.retain(|p| p.exists()); + + recent + } + + /// Save recent files to disk. + pub fn save(&self) { + let Some(path) = Self::config_path() else { + log::warn!("Could not determine config path for recent files"); + return; + }; + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("Failed to create config directory: {}", e); + return; + } + } + + let Ok(json) = serde_json::to_string_pretty(&self) else { + log::warn!("Failed to serialize recent files"); + return; + }; + + if let Err(e) = std::fs::write(&path, json) { + log::warn!("Failed to save recent files: {}", e); + } + } + + /// Add a file to the recent files list. + /// If the file already exists in the list, it's moved to the top. + /// The list is trimmed to MAX_RECENT_FILES entries. + pub fn add_file(&mut self, path: PathBuf) { + // Canonicalize path if possible for consistent comparison + let path = path.canonicalize().unwrap_or(path); + + // Remove existing entry if present + self.files.retain(|p| { + p.canonicalize().unwrap_or_else(|_| p.clone()) != path + }); + + // Add to the front + self.files.insert(0, path); + + // Trim to max size + self.files.truncate(MAX_RECENT_FILES); + } + + /// Get the list of recent files, filtering out non-existent files. + pub fn files(&self) -> Vec { + self.files.iter().filter(|p| p.exists()).cloned().collect() + } + + /// Clear all recent files. + pub fn clear(&mut self) { + self.files.clear(); + } + + /// Get the path to the config file. + fn config_path() -> Option { + let proj_dirs = directories::ProjectDirs::from("net", "nodebox", "NodeBox")?; + Some(proj_dirs.config_dir().join(RECENT_FILES_FILENAME)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use tempfile::tempdir; + + #[test] + fn test_add_file_moves_to_top() { + let dir = tempdir().unwrap(); + let file1 = dir.path().join("file1.ndbx"); + let file2 = dir.path().join("file2.ndbx"); + let file3 = dir.path().join("file3.ndbx"); + + // Create the files so they exist + File::create(&file1).unwrap(); + File::create(&file2).unwrap(); + File::create(&file3).unwrap(); + + // Canonicalize for comparison (handles /var vs /private/var on macOS) + let file1_canon = file1.canonicalize().unwrap(); + let file2_canon = file2.canonicalize().unwrap(); + let file3_canon = file3.canonicalize().unwrap(); + + let mut recent = RecentFiles::new(); + recent.add_file(file1.clone()); + recent.add_file(file2.clone()); + recent.add_file(file3.clone()); + + // file3 should be at top + let files = recent.files(); + assert_eq!(files[0], file3_canon); + assert_eq!(files[1], file2_canon); + assert_eq!(files[2], file1_canon); + + // Adding file1 again should move it to top + recent.add_file(file1.clone()); + let files = recent.files(); + assert_eq!(files[0], file1_canon); + assert_eq!(files[1], file3_canon); + assert_eq!(files[2], file2_canon); + } + + #[test] + fn test_max_files_limit() { + let dir = tempdir().unwrap(); + let mut recent = RecentFiles::new(); + + // Add more than MAX_RECENT_FILES + for i in 0..15 { + let path = dir.path().join(format!("file{}.ndbx", i)); + File::create(&path).unwrap(); + recent.add_file(path); + } + + assert_eq!(recent.files().len(), MAX_RECENT_FILES); + } + + #[test] + fn test_clear() { + let dir = tempdir().unwrap(); + let file1 = dir.path().join("file1.ndbx"); + File::create(&file1).unwrap(); + + let mut recent = RecentFiles::new(); + recent.add_file(file1); + assert_eq!(recent.files().len(), 1); + + recent.clear(); + assert_eq!(recent.files().len(), 0); + } + + #[test] + fn test_nonexistent_files_filtered() { + let dir = tempdir().unwrap(); + let file1 = dir.path().join("file1.ndbx"); + let file2 = dir.path().join("file2.ndbx"); + + // Only create file1 + File::create(&file1).unwrap(); + + let mut recent = RecentFiles::new(); + recent.files = vec![file1.clone(), file2.clone()]; + + // files() should filter out file2 + let files = recent.files(); + assert_eq!(files.len(), 1); + assert_eq!(files[0], file1); + } +} diff --git a/crates/nodebox-desktop/src/render_worker.rs b/crates/nodebox-desktop/src/render_worker.rs new file mode 100644 index 00000000..e3758f15 --- /dev/null +++ b/crates/nodebox-desktop/src/render_worker.rs @@ -0,0 +1,300 @@ +//! Background render worker for non-blocking network evaluation. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc}; +use std::thread; +use std::time::Instant; +use nodebox_core::geometry::Path as GeoPath; +use nodebox_core::node::NodeLibrary; +use nodebox_core::platform::{Platform, ProjectContext}; +use crate::eval::{NodeError, NodeOutput}; + +/// Token for cooperative cancellation of render operations. +/// +/// The token is shared between the main thread and the render worker. +/// When cancelled, the worker should check `is_cancelled()` at appropriate +/// boundaries (per-node and per-iteration) and return early. +#[derive(Clone)] +pub struct CancellationToken { + cancelled: Arc, +} + +impl CancellationToken { + /// Create a new cancellation token in the non-cancelled state. + pub fn new() -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + } + } + + /// Request cancellation. This is thread-safe and can be called from any thread. + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + } + + /// Check if cancellation has been requested. + /// Call this at appropriate boundaries (before each node, during iterations). + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } +} + +impl Default for CancellationToken { + fn default() -> Self { + Self::new() + } +} + +/// Unique identifier for a render request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RenderRequestId(u64); + +/// A request sent to the render worker. +pub enum RenderRequest { + /// Evaluate the network and return geometry. + Evaluate { + id: RenderRequestId, + library: Arc, + cancel_token: CancellationToken, + port: Arc, + project_context: ProjectContext, + }, + /// Shut down the worker thread. + Shutdown, +} + +/// A result returned from the render worker. +#[allow(dead_code)] +pub enum RenderResult { + /// Evaluation completed (may include errors). + Success { + id: RenderRequestId, + geometry: Vec, + output: crate::eval::NodeOutput, + errors: Vec, + }, + /// Evaluation was cancelled before completion. + Cancelled { + id: RenderRequestId, + }, + /// Evaluation failed completely (e.g., panic in worker). + Error { id: RenderRequestId, message: String }, +} + +/// Tracks the state of pending and completed renders. +pub struct RenderState { + next_id: u64, + latest_dispatched_id: Option, + /// Whether a render is currently in progress. + pub is_rendering: bool, + /// When the current render started (for UI feedback). + pub render_start_time: Option, + /// Current cancellation token (if render is in progress). + current_cancel_token: Option, +} + +impl RenderState { + /// Create a new render state. + pub fn new() -> Self { + Self { + next_id: 0, + latest_dispatched_id: None, + is_rendering: false, + render_start_time: None, + current_cancel_token: None, + } + } + + /// Dispatch a new render request and return its ID and cancellation token. + pub fn dispatch_new(&mut self) -> (RenderRequestId, CancellationToken) { + let id = RenderRequestId(self.next_id); + self.next_id += 1; + self.latest_dispatched_id = Some(id); + self.is_rendering = true; + self.render_start_time = Some(Instant::now()); + + let token = CancellationToken::new(); + self.current_cancel_token = Some(token.clone()); + + (id, token) + } + + /// Check if the given ID is the most recently dispatched. + pub fn is_current(&self, id: RenderRequestId) -> bool { + self.latest_dispatched_id == Some(id) + } + + /// Mark the current render as complete. + pub fn complete(&mut self) { + self.is_rendering = false; + self.render_start_time = None; + self.current_cancel_token = None; + } + + /// Cancel the current render if one is in progress. + pub fn cancel(&self) { + if let Some(ref token) = self.current_cancel_token { + token.cancel(); + } + } + + /// Get elapsed time since render started, if rendering. + pub fn elapsed(&self) -> Option { + self.render_start_time.map(|t| t.elapsed()) + } +} + +impl Default for RenderState { + fn default() -> Self { + Self::new() + } +} + +/// Handle to the background render worker thread. +pub struct RenderWorkerHandle { + request_tx: Option>, + result_rx: mpsc::Receiver, + thread_handle: Option>, +} + +impl RenderWorkerHandle { + /// Spawn a new render worker thread. + pub fn spawn() -> Self { + let (request_tx, request_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + + let thread_handle = thread::spawn(move || { + render_worker_loop(request_rx, result_tx); + }); + + Self { + request_tx: Some(request_tx), + result_rx, + thread_handle: Some(thread_handle), + } + } + + /// Request a render of the given library with cancellation support. + pub fn request_render( + &self, + id: RenderRequestId, + library: Arc, + cancel_token: CancellationToken, + port: Arc, + project_context: ProjectContext, + ) { + if let Some(ref tx) = self.request_tx { + let _ = tx.send(RenderRequest::Evaluate { + id, + library, + cancel_token, + port, + project_context, + }); + } + } + + /// Try to receive a render result without blocking. + pub fn try_recv_result(&self) -> Option { + self.result_rx.try_recv().ok() + } + + /// Shut down the render worker thread. + pub fn shutdown(&mut self) { + // Send shutdown message + if let Some(tx) = self.request_tx.take() { + let _ = tx.send(RenderRequest::Shutdown); + } + // Wait for thread to finish + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for RenderWorkerHandle { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// The main loop of the render worker thread. +fn render_worker_loop( + request_rx: mpsc::Receiver, + result_tx: mpsc::Sender, +) { + // Cache persists across renders - lives in worker thread only. + // This avoids race conditions from cross-thread cache sharing. + let mut node_cache: HashMap = HashMap::new(); + + loop { + match request_rx.recv() { + Ok(RenderRequest::Evaluate { id, library, cancel_token, port, project_context }) => { + // Drain to the latest request (skip stale ones) + let (final_id, final_library, final_token, final_port, final_project_context) = + drain_to_latest(id, library, cancel_token, port, project_context, &request_rx); + + // Clear cache when library changes to ensure fresh evaluation. + // Future optimization: use hash-based cache keys so unchanged nodes stay cached. + node_cache.clear(); + + // Evaluate the network with cancellation support + let result = crate::eval::evaluate_network_cancellable( + &final_library, + &final_token, + &mut node_cache, + &final_port, + &final_project_context, + ); + + match result { + crate::eval::EvalOutcome::Completed { geometry, output, errors } => { + let _ = result_tx.send(RenderResult::Success { + id: final_id, + geometry, + output, + errors, + }); + } + crate::eval::EvalOutcome::Cancelled => { + let _ = result_tx.send(RenderResult::Cancelled { + id: final_id, + }); + } + } + } + Ok(RenderRequest::Shutdown) | Err(_) => break, + } + } +} + +/// Drain any pending requests and return the most recent one. +fn drain_to_latest( + mut id: RenderRequestId, + mut library: Arc, + mut cancel_token: CancellationToken, + mut port: Arc, + mut project_context: ProjectContext, + rx: &mpsc::Receiver, +) -> (RenderRequestId, Arc, CancellationToken, Arc, ProjectContext) { + while let Ok(req) = rx.try_recv() { + match req { + RenderRequest::Evaluate { + id: new_id, + library: new_lib, + cancel_token: new_token, + port: new_port, + project_context: new_ctx, + } => { + id = new_id; + library = new_lib; + cancel_token = new_token; + port = new_port; + project_context = new_ctx; + } + RenderRequest::Shutdown => break, + } + } + (id, library, cancel_token, port, project_context) +} diff --git a/crates/nodebox-desktop/src/state.rs b/crates/nodebox-desktop/src/state.rs new file mode 100644 index 00000000..4ab320d2 --- /dev/null +++ b/crates/nodebox-desktop/src/state.rs @@ -0,0 +1,781 @@ +//! Application state management. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use nodebox_core::geometry::{Path as GeoPath, Color, Point}; +use nodebox_core::node::{Node, NodeLibrary, MenuItem, Port, PortRange, Widget}; +use crate::eval::NodeOutput; + +/// Severity level for a notification. +#[derive(Debug, Clone, PartialEq)] +pub enum NotificationLevel { + /// Informational notice. + #[allow(dead_code)] + Info, + /// Warning about potential issues. + Warning, +} + +/// A dismissible notification shown to the user. +#[derive(Debug, Clone)] +pub struct Notification { + /// Unique identifier for this notification. + pub id: u64, + /// The message to display. + pub message: String, + /// Severity level. + pub level: NotificationLevel, +} + +/// The main application state. +pub struct AppState { + /// Current file path (if saved). + pub current_file: Option, + + /// Whether the document has unsaved changes. + pub dirty: bool, + + /// Whether to show the about dialog. + pub show_about: bool, + + /// The current geometry to render. + pub geometry: Vec, + + /// Currently selected node (if any). + pub selected_node: Option, + + /// Canvas background color. + pub background_color: Color, + + /// The node library (document). + /// Wrapped in Arc for cheap cloning when dispatching renders. + /// Use `Arc::make_mut` for copy-on-write mutation. + pub library: Arc, + + /// Per-node error messages (node_name -> error message). + pub node_errors: HashMap, + + /// The raw output of the rendered node (for non-geometry data display). + pub node_output: NodeOutput, + + /// Active notifications (dismissible banners). + pub notifications: Vec, + + /// Counter for generating unique notification IDs. + notification_counter: u64, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + /// Create a new application state with demo content. + /// + /// Note: Geometry starts empty - the render worker will evaluate with + /// the proper Port and populate it. + pub fn new() -> Self { + let library = Arc::new(Self::create_demo_library()); + + Self { + current_file: None, + dirty: false, + show_about: false, + geometry: Vec::new(), // Render worker will populate + selected_node: None, + background_color: Color::rgb(232.0 / 255.0, 232.0 / 255.0, 232.0 / 255.0), + library, + node_errors: HashMap::new(), + node_output: NodeOutput::None, + notifications: Vec::new(), + notification_counter: 0, + } + } + + /// Create a demo node library with a single rect node. + fn create_demo_library() -> NodeLibrary { + let mut library = NodeLibrary::new("demo"); + + let rect_node = Node::new("rect1") + .with_prototype("corevector.rect") + .with_function("corevector/rect") + .with_category("geometry") + .with_position(1.0, 1.0) + .with_input(Port::point("position", nodebox_core::geometry::Point::ZERO)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)) + .with_input(Port::point("roundness", nodebox_core::geometry::Point::ZERO)); + + library.root = Node::network("root") + .with_child(rect_node) + .with_rendered_child("rect1"); + + library + } + + /// Create a new empty document. + pub fn new_document(&mut self) { + self.current_file = None; + self.dirty = false; + self.geometry.clear(); + self.node_output = NodeOutput::None; + self.selected_node = None; + self.node_errors.clear(); + self.notifications.clear(); + } + + /// Load a file. + /// + /// Note: Geometry is cleared - the render worker will evaluate with + /// the proper Port and populate it. + pub fn load_file(&mut self, path: &Path) -> Result<(), String> { + // Parse the .ndbx file with warnings (old format versions load best-effort) + let (mut library, warnings) = + nodebox_core::ndbx::parse_file_with_warnings(path).map_err(|e| e.to_string())?; + + // Ensure all nodes have their default ports populated + populate_default_ports(&mut library.root); + + // Update state + self.library = Arc::new(library); + self.background_color = self.library.background_color(); + self.current_file = Some(path.to_path_buf()); + self.dirty = false; + self.selected_node = None; + self.geometry.clear(); // Render worker will populate + self.node_output = NodeOutput::None; + self.node_errors.clear(); + + // Surface any warnings as notifications + self.notifications.clear(); + for warning in warnings { + self.add_notification(warning, NotificationLevel::Warning); + } + + Ok(()) + } + + /// Add a notification and return its ID. + pub fn add_notification(&mut self, message: String, level: NotificationLevel) -> u64 { + self.notification_counter += 1; + let id = self.notification_counter; + self.notifications.push(Notification { id, message, level }); + id + } + + /// Dismiss (remove) a notification by ID. + pub fn dismiss_notification(&mut self, id: u64) { + self.notifications.retain(|n| n.id != id); + } + + /// Save the current document. + pub fn save_file(&mut self, path: &Path) -> Result<(), String> { + nodebox_core::ndbx::serialize_to_file(&self.library, path) + .map_err(|e| e.to_string())?; + self.current_file = Some(path.to_path_buf()); + self.dirty = false; + Ok(()) + } + + /// Export to SVG. + /// Uses document width/height and centered coordinate system. + pub fn export_svg(&self, path: &Path, width: f64, height: f64) -> Result<(), String> { + let options = nodebox_core::svg::SvgOptions::new(width, height) + .with_centered(true) + .with_background(Some(self.background_color)); + let svg = nodebox_core::svg::render_to_svg_with_options(&self.geometry, &options); + std::fs::write(path, svg).map_err(|e| e.to_string()) + } +} + +/// Populate default ports for nodes based on their prototype. +/// +/// When loading .ndbx files, only non-default port values are stored. +/// This function adds the missing default ports that nodes need for +/// connections to work properly. +pub fn populate_default_ports(node: &mut Node) { + // Recursively process children first + for child in &mut node.children { + populate_default_ports(child); + } + + // Add default ports based on prototype + if let Some(ref proto) = node.prototype { + match proto.as_str() { + // Geometry generators - port names match corevector.ndbx library + "corevector.ellipse" => { + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "width", || Port::float("width", 100.0)); + ensure_port(node, "height", || Port::float("height", 100.0)); + } + "corevector.rect" => { + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "width", || Port::float("width", 100.0)); + ensure_port(node, "height", || Port::float("height", 100.0)); + ensure_port(node, "roundness", || Port::point("roundness", nodebox_core::geometry::Point::ZERO)); + } + "corevector.line" => { + ensure_port(node, "point1", || Port::point("point1", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "point2", || Port::point("point2", nodebox_core::geometry::Point::new(100.0, 100.0))); + ensure_port(node, "points", || Port::int("points", 2)); + } + "corevector.polygon" => { + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "radius", || Port::float("radius", 100.0)); + ensure_port(node, "sides", || Port::int("sides", 3)); + ensure_port(node, "align", || Port::boolean("align", false)); + } + "corevector.star" => { + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "points", || Port::int("points", 20)); + ensure_port(node, "outer", || Port::float("outer", 200.0)); + ensure_port(node, "inner", || Port::float("inner", 100.0)); + } + "corevector.arc" => { + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "width", || Port::float("width", 100.0)); + ensure_port(node, "height", || Port::float("height", 100.0)); + ensure_port(node, "start_angle", || Port::float("start_angle", 0.0)); + ensure_port(node, "degrees", || Port::float("degrees", 45.0)); + ensure_port(node, "type", || Port::menu("type", "pie", vec![ + MenuItem::new("pie", "Pie"), + MenuItem::new("chord", "Chord"), + MenuItem::new("open", "Open"), + ])); + } + // Filters + "corevector.colorize" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "fill", || Port::color("fill", Color::WHITE)); + ensure_port(node, "stroke", || Port::color("stroke", Color::BLACK)); + ensure_port(node, "strokeWidth", || Port::float("strokeWidth", 1.0)); + } + "corevector.translate" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "translate", || Port::point("translate", nodebox_core::geometry::Point::ZERO)); + } + "corevector.rotate" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "angle", || Port::float("angle", 0.0)); + ensure_port(node, "origin", || Port::point("origin", nodebox_core::geometry::Point::ZERO)); + } + "corevector.scale" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "scale", || Port::point("scale", nodebox_core::geometry::Point::new(100.0, 100.0))); + ensure_port(node, "origin", || Port::point("origin", nodebox_core::geometry::Point::ZERO)); + } + "corevector.copy" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "copies", || Port::int("copies", 1)); + ensure_port(node, "order", || Port::menu("order", "tsr", vec![ + MenuItem::new("srt", "Scale Rot Trans"), + MenuItem::new("str", "Scale Trans Rot"), + MenuItem::new("rst", "Rot Scale Trans"), + MenuItem::new("rtr", "Rot Trans Scale"), + MenuItem::new("tsr", "Trans Scale Rot"), + MenuItem::new("trs", "Trans Rot Scale"), + ])); + ensure_port(node, "translate", || Port::point("translate", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "rotate", || Port::float("rotate", 0.0)); + ensure_port(node, "scale", || Port::point("scale", nodebox_core::geometry::Point::new(100.0, 100.0))); + } + "corevector.align" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "halign", || Port::menu("halign", "center", vec![ + MenuItem::new("none", "No Change"), + MenuItem::new("left", "Left"), + MenuItem::new("center", "Center"), + MenuItem::new("right", "Right"), + ])); + ensure_port(node, "valign", || Port::menu("valign", "middle", vec![ + MenuItem::new("none", "No Change"), + MenuItem::new("top", "Top"), + MenuItem::new("middle", "Middle"), + MenuItem::new("bottom", "Bottom"), + ])); + } + "corevector.fit" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "width", || Port::float("width", 300.0)); + ensure_port(node, "height", || Port::float("height", 300.0)); + ensure_port(node, "keep_proportions", || Port::boolean("keep_proportions", true)); + } + "corevector.resample" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "method", || Port::menu("method", "length", vec![ + MenuItem::new("length", "By length"), + MenuItem::new("amount", "By amount"), + ])); + ensure_port(node, "length", || Port::float("length", 10.0)); + ensure_port(node, "points", || Port::int("points", 10)); + ensure_port(node, "per_contour", || Port::boolean("per_contour", false)); + } + "corevector.wiggle" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "scope", || Port::menu("scope", "points", vec![ + MenuItem::new("points", "Points"), + MenuItem::new("contours", "Contours"), + MenuItem::new("paths", "Paths"), + ])); + ensure_port(node, "offset", || Port::point("offset", nodebox_core::geometry::Point::new(10.0, 10.0))); + ensure_port(node, "seed", || Port::int("seed", 0)); + } + // Combine operations + "corevector.merge" | "corevector.combine" => { + // shapes port expects a list of shapes, not individual values to iterate over + ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); + } + "corevector.group" => { + ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); + } + "corevector.stack" => { + ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); + ensure_port(node, "direction", || Port::menu("direction", "e", vec![ + MenuItem::new("n", "North"), + MenuItem::new("e", "East"), + MenuItem::new("s", "South"), + MenuItem::new("w", "West"), + ])); + ensure_port(node, "margin", || Port::float("margin", 5.0)); + } + "corevector.sort" => { + ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); + ensure_port(node, "order_by", || Port::menu("order_by", "none", vec![ + MenuItem::new("none", "No Change"), + MenuItem::new("x", "X"), + MenuItem::new("y", "Y"), + MenuItem::new("angle", "Angle to Point"), + MenuItem::new("distance", "Distance to Point"), + ])); + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + } + "list.combine" => { + // list.combine ports should be LIST-range so empty inputs don't block evaluation + ensure_port(node, "list1", || Port::geometry("list1").with_port_range(PortRange::List)); + ensure_port(node, "list2", || Port::geometry("list2").with_port_range(PortRange::List)); + ensure_port(node, "list3", || Port::geometry("list3").with_port_range(PortRange::List)); + ensure_port(node, "list4", || Port::geometry("list4").with_port_range(PortRange::List)); + ensure_port(node, "list5", || Port::geometry("list5").with_port_range(PortRange::List)); + } + // Grid + "corevector.grid" => { + ensure_port(node, "columns", || Port::int("columns", 10)); + ensure_port(node, "rows", || Port::int("rows", 10)); + ensure_port(node, "width", || Port::float("width", 300.0)); + ensure_port(node, "height", || Port::float("height", 300.0)); + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + } + // Connect + "corevector.connect" => { + // points port expects a list of points, not individual values to iterate over + ensure_port(node, "points", || Port::geometry("points").with_port_range(PortRange::List)); + ensure_port(node, "closed", || Port::boolean("closed", false)); + } + // Point + "corevector.point" | "corevector.makePoint" => { + ensure_port(node, "x", || Port::float("x", 0.0)); + ensure_port(node, "y", || Port::float("y", 0.0)); + } + "corevector.quad_curve" => { + ensure_port(node, "point1", || Port::point("point1", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "point2", || Port::point("point2", nodebox_core::geometry::Point::new(100.0, 0.0))); + ensure_port(node, "t", || Port::float("t", 50.0)); + ensure_port(node, "distance", || Port::float("distance", 50.0)); + } + "corevector.compound" => { + ensure_port(node, "shape1", || Port::geometry("shape1")); + ensure_port(node, "shape2", || Port::geometry("shape2")); + ensure_port(node, "function", || Port::menu("function", "united", vec![ + MenuItem::new("united", "Union"), + MenuItem::new("subtracted", "Difference"), + MenuItem::new("intersected", "Intersection"), + ])); + ensure_port(node, "invert_difference", || Port::boolean("invert_difference", false)); + } + "corevector.link" => { + ensure_port(node, "shape1", || Port::geometry("shape1")); + ensure_port(node, "shape2", || Port::geometry("shape2")); + ensure_port(node, "orientation", || Port::menu("orientation", "horizontal", vec![ + MenuItem::new("horizontal", "Horizontal"), + MenuItem::new("vertical", "Vertical"), + ])); + } + "corevector.textpath" => { + ensure_port(node, "text", || Port::string("text", "hello")); + ensure_port(node, "font_name", || Port::string("font_name", "Verdana").with_widget(Widget::Font)); + ensure_port(node, "font_size", || Port::float("font_size", 24.0)); + ensure_port(node, "align", || Port::menu("align", "CENTER", vec![ + MenuItem::new("LEFT", "Left"), + MenuItem::new("CENTER", "Center"), + MenuItem::new("RIGHT", "Right"), + MenuItem::new("JUSTIFY", "Justify"), + ])); + ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); + ensure_port(node, "width", || Port::float("width", 0.0)); + } + "corevector.delete" => { + ensure_port(node, "shape", || Port::geometry("shape")); + ensure_port(node, "bounding", || Port::geometry("bounding")); + ensure_port(node, "scope", || Port::menu("scope", "points", vec![ + MenuItem::new("points", "Points"), + MenuItem::new("paths", "Paths"), + ])); + ensure_port(node, "operation", || Port::menu("operation", "selected", vec![ + MenuItem::new("selected", "Delete Selected"), + MenuItem::new("non-selected", "Delete Non-selected"), + ])); + } + "corevector.distribute" => { + ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); + ensure_port(node, "horizontal", || Port::menu("horizontal", "none", vec![ + MenuItem::new("none", "No Change"), + MenuItem::new("left", "Left"), + MenuItem::new("center", "Center"), + MenuItem::new("right", "Right"), + ])); + ensure_port(node, "vertical", || Port::menu("vertical", "none", vec![ + MenuItem::new("none", "No Change"), + MenuItem::new("top", "Top"), + MenuItem::new("middle", "Middle"), + MenuItem::new("bottom", "Bottom"), + ])); + } + "corevector.shape_on_path" => { + ensure_port(node, "shape", || Port::geometry("shape").with_port_range(PortRange::List)); + ensure_port(node, "path", || Port::geometry("path")); + ensure_port(node, "amount", || Port::int("amount", 1)); + ensure_port(node, "alignment", || Port::menu("alignment", "leading", vec![ + MenuItem::new("leading", "Leading"), + MenuItem::new("trailing", "Trailing"), + MenuItem::new("distributed", "Distributed"), + ])); + ensure_port(node, "spacing", || Port::float("spacing", 20.0)); + ensure_port(node, "margin", || Port::float("margin", 0.0)); + ensure_port(node, "baseline_offset", || Port::float("baseline_offset", 0.0)); + } + "corevector.text_on_path" => { + ensure_port(node, "text", || Port::string("text", "text following a path")); + ensure_port(node, "path", || Port::geometry("path")); + ensure_port(node, "font_name", || Port::string("font_name", "Verdana").with_widget(Widget::Font)); + ensure_port(node, "font_size", || Port::float("font_size", 24.0)); + ensure_port(node, "alignment", || Port::menu("alignment", "leading", vec![ + MenuItem::new("leading", "Leading"), + MenuItem::new("trailing", "Trailing"), + ])); + ensure_port(node, "margin", || Port::float("margin", 0.0)); + ensure_port(node, "baseline_offset", || Port::float("baseline_offset", 0.0)); + } + // ======================== + // Math nodes + // ======================== + "math.number" => { + ensure_port(node, "value", || Port::float("value", 0.0)); + } + "math.integer" => { + ensure_port(node, "value", || Port::int("value", 0)); + } + "math.boolean" => { + ensure_port(node, "value", || Port::boolean("value", false)); + } + "math.add" | "math.subtract" | "math.multiply" | "math.divide" | "math.mod" | "math.pow" => { + ensure_port(node, "value1", || Port::float("value1", 0.0)); + ensure_port(node, "value2", || Port::float("value2", 0.0)); + } + "math.negate" | "math.abs" | "math.sqrt" | "math.log" | "math.ceil" | "math.floor" | "math.round" | "math.sin" | "math.cos" | "math.even" | "math.odd" => { + ensure_port(node, "value", || Port::float("value", 0.0)); + } + "math.radians" => { + ensure_port(node, "degrees", || Port::float("degrees", 0.0)); + } + "math.degrees" => { + ensure_port(node, "radians", || Port::float("radians", 0.0)); + } + "math.compare" => { + ensure_port(node, "value1", || Port::float("value1", 0.0)); + ensure_port(node, "value2", || Port::float("value2", 0.0)); + ensure_port(node, "comparator", || Port::menu("comparator", "<", vec![ + MenuItem::new("<", "Less Than"), + MenuItem::new(">", "Greater Than"), + MenuItem::new("<=", "Less or Equal"), + MenuItem::new(">=", "Greater or Equal"), + MenuItem::new("==", "Equal"), + MenuItem::new("!=", "Not Equal"), + ])); + } + "math.logical" => { + ensure_port(node, "boolean1", || Port::boolean("boolean1", false)); + ensure_port(node, "boolean2", || Port::boolean("boolean2", false)); + ensure_port(node, "comparator", || Port::menu("comparator", "or", vec![ + MenuItem::new("or", "Or"), + MenuItem::new("and", "And"), + MenuItem::new("xor", "Xor"), + ])); + } + "math.angle" | "math.distance" => { + ensure_port(node, "point1", || Port::point("point1", Point::ZERO)); + ensure_port(node, "point2", || Port::point("point2", Point::new(100.0, 100.0))); + } + "math.coordinates" => { + ensure_port(node, "position", || Port::point("position", Point::ZERO)); + ensure_port(node, "angle", || Port::float("angle", 0.0)); + ensure_port(node, "distance", || Port::float("distance", 100.0)); + } + "math.reflect" => { + ensure_port(node, "point1", || Port::point("point1", Point::ZERO)); + ensure_port(node, "point2", || Port::point("point2", Point::new(100.0, 100.0))); + ensure_port(node, "angle", || Port::float("angle", 0.0)); + ensure_port(node, "distance", || Port::float("distance", 1.0)); + } + "math.sum" | "math.average" | "math.max" | "math.min" | "math.running_total" => { + ensure_port(node, "values", || Port::float("values", 0.0).with_port_range(PortRange::List)); + } + "math.convert_range" => { + ensure_port(node, "value", || Port::float("value", 50.0)); + ensure_port(node, "source_start", || Port::float("source_start", 0.0)); + ensure_port(node, "source_end", || Port::float("source_end", 100.0)); + ensure_port(node, "target_start", || Port::float("target_start", 0.0)); + ensure_port(node, "target_end", || Port::float("target_end", 1.0)); + ensure_port(node, "method", || Port::menu("method", "clamp", vec![ + MenuItem::new("clamp", "Clamp"), + MenuItem::new("wrap", "Wrap"), + MenuItem::new("mirror", "Mirror"), + MenuItem::new("ignore", "Ignore"), + ])); + } + "math.wave" => { + ensure_port(node, "min", || Port::float("min", 0.0)); + ensure_port(node, "max", || Port::float("max", 100.0)); + ensure_port(node, "period", || Port::float("period", 60.0)); + ensure_port(node, "offset", || Port::float("offset", 0.0)); + ensure_port(node, "type", || Port::menu("type", "sine", vec![ + MenuItem::new("sine", "Sine"), + MenuItem::new("square", "Square"), + MenuItem::new("triangle", "Triangle"), + MenuItem::new("sawtooth", "Sawtooth"), + ])); + } + "math.make_numbers" => { + ensure_port(node, "string", || Port::string("string", "11;22;33")); + ensure_port(node, "separator", || Port::string("separator", ";")); + } + "math.random_numbers" => { + ensure_port(node, "amount", || Port::int("amount", 10)); + ensure_port(node, "start", || Port::float("start", 0.0)); + ensure_port(node, "end", || Port::float("end", 100.0)); + ensure_port(node, "seed", || Port::int("seed", 0)); + } + "math.sample" => { + ensure_port(node, "amount", || Port::int("amount", 10)); + ensure_port(node, "start", || Port::float("start", 0.0)); + ensure_port(node, "end", || Port::float("end", 100.0)); + } + "math.range" => { + ensure_port(node, "start", || Port::float("start", 0.0)); + ensure_port(node, "end", || Port::float("end", 10.0)); + ensure_port(node, "step", || Port::float("step", 1.0)); + } + + // ======================== + // String nodes + // ======================== + "string.string" => { + ensure_port(node, "value", || Port::string("value", "")); + } + "string.length" | "string.word_count" | "string.trim" | "string.characters" => { + ensure_port(node, "string", || Port::string("string", "")); + } + "string.concatenate" => { + ensure_port(node, "string1", || Port::string("string1", "")); + ensure_port(node, "string2", || Port::string("string2", "")); + ensure_port(node, "string3", || Port::string("string3", "")); + ensure_port(node, "string4", || Port::string("string4", "")); + ensure_port(node, "string5", || Port::string("string5", "")); + ensure_port(node, "string6", || Port::string("string6", "")); + ensure_port(node, "string7", || Port::string("string7", "")); + } + "string.change_case" => { + ensure_port(node, "string", || Port::string("string", "default")); + ensure_port(node, "method", || Port::menu("method", "uppercase", vec![ + MenuItem::new("lowercase", "Lower Case"), + MenuItem::new("uppercase", "Upper Case"), + MenuItem::new("titlecase", "Title Case"), + ])); + } + "string.format_number" => { + ensure_port(node, "value", || Port::float("value", 0.0)); + ensure_port(node, "format", || Port::string("format", "%.2f")); + } + "string.replace" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "old", || Port::string("old", "")); + ensure_port(node, "new", || Port::string("new", "")); + } + "string.sub_string" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "start", || Port::int("start", 0)); + ensure_port(node, "end", || Port::int("end", 4)); + ensure_port(node, "end_offset", || Port::boolean("end_offset", false)); + } + "string.character_at" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "index", || Port::int("index", 0)); + } + "string.as_binary_string" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "digit_separator", || Port::string("digit_separator", "")); + ensure_port(node, "byte_separator", || Port::string("byte_separator", " ")); + } + "string.as_binary_list" | "string.as_number_list" => { + ensure_port(node, "string", || Port::string("string", "")); + } + "string.contains" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "contains", || Port::string("contains", "")); + } + "string.ends_with" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "ends_with", || Port::string("ends_with", "")); + } + "string.starts_with" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "starts_with", || Port::string("starts_with", "")); + } + "string.equals" => { + ensure_port(node, "string", || Port::string("string", "")); + ensure_port(node, "equals", || Port::string("equals", "")); + ensure_port(node, "case_sensitive", || Port::boolean("case_sensitive", false)); + } + "string.make_strings" => { + ensure_port(node, "string", || Port::string("string", "Alpha;Beta;Gamma")); + ensure_port(node, "separator", || Port::string("separator", ";")); + } + "string.random_character" => { + ensure_port(node, "characters", || Port::string("characters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")); + ensure_port(node, "amount", || Port::int("amount", 10)); + ensure_port(node, "seed", || Port::int("seed", 0)); + } + + // ======================== + // List nodes + // ======================== + "list.count" | "list.first" | "list.second" | "list.last" | "list.rest" | "list.reverse" | "list.distinct" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + } + "list.slice" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "start_index", || Port::int("start_index", 0)); + ensure_port(node, "size", || Port::int("size", 10)); + ensure_port(node, "invert", || Port::boolean("invert", false)); + } + "list.shift" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "amount", || Port::int("amount", 1)); + } + "list.repeat" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "amount", || Port::int("amount", 1)); + ensure_port(node, "per_item", || Port::boolean("per_item", false)); + } + "list.sort" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "key", || Port::string("key", "")); + } + "list.shuffle" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "seed", || Port::int("seed", 0)); + } + "list.pick" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "amount", || Port::int("amount", 5)); + ensure_port(node, "seed", || Port::int("seed", 0)); + } + "list.cull" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "booleans", || Port::boolean("booleans", true).with_port_range(PortRange::List)); + } + "list.take_every" => { + ensure_port(node, "list", || Port::geometry("list").with_port_range(PortRange::List)); + ensure_port(node, "n", || Port::int("n", 1)); + } + "list.switch" => { + ensure_port(node, "input1", || Port::geometry("input1").with_port_range(PortRange::List)); + ensure_port(node, "input2", || Port::geometry("input2").with_port_range(PortRange::List)); + ensure_port(node, "index", || Port::int("index", 0)); + } + + // ======================== + // Color nodes + // ======================== + "color.color" => { + ensure_port(node, "color", || Port::color("color", Color::BLACK)); + } + "color.gray_color" => { + ensure_port(node, "gray", || Port::float("gray", 0.0)); + ensure_port(node, "alpha", || Port::float("alpha", 255.0)); + ensure_port(node, "range", || Port::float("range", 255.0)); + } + "color.rgb_color" => { + ensure_port(node, "red", || Port::float("red", 0.0)); + ensure_port(node, "green", || Port::float("green", 0.0)); + ensure_port(node, "blue", || Port::float("blue", 0.0)); + ensure_port(node, "alpha", || Port::float("alpha", 255.0)); + ensure_port(node, "range", || Port::float("range", 255.0)); + } + "color.hsb_color" => { + ensure_port(node, "hue", || Port::float("hue", 0.0)); + ensure_port(node, "saturation", || Port::float("saturation", 0.0)); + ensure_port(node, "brightness", || Port::float("brightness", 0.0)); + ensure_port(node, "alpha", || Port::float("alpha", 255.0)); + ensure_port(node, "range", || Port::float("range", 255.0)); + } + + // ======================== + // Network nodes + // ======================== + "network.http_get" => { + ensure_port(node, "url", || Port::string("url", "")); + } + "network.encode_url" => { + ensure_port(node, "value", || Port::string("value", "")); + } + + // ======================== + // Data nodes + // ======================== + "data.import_text" | "data.import_csv" => { + ensure_port(node, "file", || Port::string("file", "").with_widget(Widget::File)); + } + + _ => {} + } + } +} + +/// Ensure a port exists on a node with the correct widget and menu items. +/// +/// If the port doesn't exist, it's created with the default. +/// If the port exists but has Widget::String and the default has Widget::Menu, +/// update the widget type and menu items (preserving the existing value). +fn ensure_port(node: &mut Node, name: &str, default: F) +where + F: FnOnce() -> Port, +{ + if let Some(existing) = node.inputs.iter_mut().find(|p| p.name == name) { + // Port exists - check if we need to update widget/menu items + let default_port = default(); + if existing.widget == Widget::String && default_port.widget == Widget::Menu { + // Update to menu widget and add menu items + existing.widget = Widget::Menu; + existing.menu_items = default_port.menu_items; + } + } else { + // Port doesn't exist - add it + node.inputs.push(default()); + } +} + diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-desktop/src/theme.rs similarity index 60% rename from crates/nodebox-gui/src/theme.rs rename to crates/nodebox-desktop/src/theme.rs index 45224d61..568ee6c8 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-desktop/src/theme.rs @@ -1,7 +1,7 @@ -//! Centralized theme constants based on Tailwind's Slate color palette. +//! Centralized theme constants based on Tailwind's Zinc color palette. //! //! Design principles: -//! - Cool blue-gray tones from Tailwind's Slate palette +//! - Neutral gray tones with subtle blue-violet undertone from Tailwind's Zinc palette //! - Purple/violet accent color for selections and highlights //! - Sharp corners (0px) for a modern, precise feel //! - Minimal borders - use background color differentiation instead @@ -14,56 +14,56 @@ use eframe::egui::{self, Color32, CornerRadius, FontId, Stroke, Style, Visuals}; // ============================================================================= -// SLATE SCALE (Tailwind v4 Slate Palette) +// ZINC SCALE (Tailwind v4 Zinc Palette) // ============================================================================= -// Cool blue-gray tones with good contrast +// Neutral gray tones with a subtle blue-violet undertone -/// Lightest - near white with cool tint -pub const SLATE_50: Color32 = Color32::from_rgb(248, 250, 252); +/// Lightest - near white +pub const ZINC_50: Color32 = Color32::from_rgb(250, 250, 250); /// Very light -pub const SLATE_100: Color32 = Color32::from_rgb(241, 245, 249); +pub const ZINC_100: Color32 = Color32::from_rgb(244, 244, 245); /// Light -pub const SLATE_200: Color32 = Color32::from_rgb(226, 232, 240); +pub const ZINC_200: Color32 = Color32::from_rgb(228, 228, 231); /// Light-medium -pub const SLATE_300: Color32 = Color32::from_rgb(202, 213, 226); +pub const ZINC_300: Color32 = Color32::from_rgb(212, 212, 216); /// Medium - good for muted text -pub const SLATE_400: Color32 = Color32::from_rgb(144, 161, 185); +pub const ZINC_400: Color32 = Color32::from_rgb(159, 159, 169); /// Medium-dark - node fills, secondary elements -pub const SLATE_500: Color32 = Color32::from_rgb(98, 116, 142); +pub const ZINC_500: Color32 = Color32::from_rgb(113, 113, 123); /// Dark - node fills, interactive elements -pub const SLATE_600: Color32 = Color32::from_rgb(69, 85, 108); +pub const ZINC_600: Color32 = Color32::from_rgb(82, 82, 92); /// Darker - elevated surfaces -pub const SLATE_700: Color32 = Color32::from_rgb(49, 65, 88); +pub const ZINC_700: Color32 = Color32::from_rgb(63, 63, 70); /// Very dark - panel backgrounds -pub const SLATE_800: Color32 = Color32::from_rgb(29, 41, 61); +pub const ZINC_800: Color32 = Color32::from_rgb(39, 39, 42); /// Near black - main backgrounds -pub const SLATE_900: Color32 = Color32::from_rgb(15, 23, 43); +pub const ZINC_900: Color32 = Color32::from_rgb(24, 24, 27); /// Deepest - true dark background -pub const SLATE_950: Color32 = Color32::from_rgb(2, 6, 24); +pub const ZINC_950: Color32 = Color32::from_rgb(9, 9, 11); // ============================================================================= -// LEGACY GRAY ALIASES (map to Slate for backward compatibility) +// LEGACY GRAY ALIASES (map to Zinc for backward compatibility) // ============================================================================= pub const GRAY_0: Color32 = Color32::from_rgb(0, 0, 0); -pub const GRAY_50: Color32 = SLATE_950; -pub const GRAY_100: Color32 = SLATE_900; -pub const GRAY_150: Color32 = SLATE_800; -pub const GRAY_200: Color32 = SLATE_700; -pub const GRAY_250: Color32 = SLATE_600; -pub const GRAY_300: Color32 = SLATE_600; -pub const GRAY_350: Color32 = SLATE_500; -pub const GRAY_400: Color32 = SLATE_500; -pub const GRAY_500: Color32 = SLATE_400; -pub const GRAY_600: Color32 = SLATE_300; -pub const GRAY_700: Color32 = SLATE_200; -pub const GRAY_800: Color32 = SLATE_100; -pub const GRAY_900: Color32 = SLATE_50; +pub const GRAY_50: Color32 = ZINC_900; +pub const GRAY_100: Color32 = ZINC_800; +pub const GRAY_150: Color32 = ZINC_700; +pub const GRAY_200: Color32 = ZINC_600; +pub const GRAY_250: Color32 = ZINC_500; +pub const GRAY_300: Color32 = ZINC_500; +pub const GRAY_350: Color32 = ZINC_400; +pub const GRAY_400: Color32 = ZINC_400; +pub const GRAY_500: Color32 = ZINC_300; +pub const GRAY_600: Color32 = ZINC_200; +pub const GRAY_700: Color32 = ZINC_100; +pub const GRAY_800: Color32 = ZINC_50; +pub const GRAY_900: Color32 = ZINC_50; pub const GRAY_1000: Color32 = Color32::from_rgb(255, 255, 255); -pub const GRAY_325: Color32 = SLATE_500; -pub const GRAY_550: Color32 = SLATE_400; -pub const GRAY_775: Color32 = SLATE_100; +pub const GRAY_325: Color32 = ZINC_400; +pub const GRAY_550: Color32 = ZINC_300; +pub const GRAY_775: Color32 = ZINC_50; // ============================================================================= // ACCENT COLORS (Purple/Violet - Linear-inspired) @@ -71,6 +71,8 @@ pub const GRAY_775: Color32 = SLATE_100; /// Selection background (subtle violet tint) pub const VIOLET_900: Color32 = Color32::from_rgb(45, 38, 64); +/// Lighter selection background (for better text contrast) +pub const VIOLET_800: Color32 = Color32::from_rgb(76, 58, 118); /// Darker pressed state pub const VIOLET_600: Color32 = Color32::from_rgb(124, 58, 237); /// Primary accent color @@ -89,7 +91,7 @@ pub const BLUE_500: Color32 = VIOLET_400; pub const SUCCESS_GREEN: Color32 = Color32::from_rgb(34, 197, 94); pub const WARNING_YELLOW: Color32 = Color32::from_rgb(234, 179, 8); -pub const ERROR_RED: Color32 = Color32::from_rgb(239, 68, 68); +pub const ERROR_RED: Color32 = Color32::from_rgb(255, 100, 103); // #ff6467 // Legacy alias pub const WARNING_ORANGE: Color32 = WARNING_YELLOW; @@ -99,51 +101,55 @@ pub const WARNING_ORANGE: Color32 = WARNING_YELLOW; // ============================================================================= /// Main panel background (dark) -pub const PANEL_BG: Color32 = SLATE_900; +pub const PANEL_BG: Color32 = ZINC_800; /// Top bar / title bar background -pub const TOP_BAR_BG: Color32 = SLATE_900; +pub const TOP_BAR_BG: Color32 = ZINC_800; /// Tab bar background -pub const TAB_BAR_BG: Color32 = SLATE_800; +pub const TAB_BAR_BG: Color32 = ZINC_700; /// Bottom bar / footer background -pub const BOTTOM_BAR_BG: Color32 = SLATE_900; +pub const BOTTOM_BAR_BG: Color32 = ZINC_800; /// Elevated surface (cards, dialogs, popups) -pub const SURFACE_ELEVATED: Color32 = SLATE_700; +pub const SURFACE_ELEVATED: Color32 = ZINC_600; /// Text edit / input field background -pub const TEXT_EDIT_BG: Color32 = SLATE_700; +pub const TEXT_EDIT_BG: Color32 = ZINC_600; /// Hover state background -pub const HOVER_BG: Color32 = SLATE_600; -/// Selection background (subtle violet) -pub const SELECTION_BG: Color32 = VIOLET_900; +pub const HOVER_BG: Color32 = ZINC_500; +/// Selection background (visible violet with good text contrast) +pub const SELECTION_BG: Color32 = VIOLET_800; +/// Text edit selection highlight (blue, readable with white text) +pub const TEXT_EDIT_SELECTION_BG: Color32 = Color32::from_rgb(37, 99, 175); +/// Field hover background (ZINC_700 at ~50% opacity over ZINC_600) +pub const FIELD_HOVER_BG: Color32 = Color32::from_rgb(72, 72, 81); // ============================================================================= // SEMANTIC COLORS - Text // ============================================================================= /// Strong/active text (brightest) -pub const TEXT_STRONG: Color32 = SLATE_50; +pub const TEXT_STRONG: Color32 = ZINC_50; /// Default body text -pub const TEXT_DEFAULT: Color32 = SLATE_200; +pub const TEXT_DEFAULT: Color32 = ZINC_100; /// Secondary/muted text -pub const TEXT_SUBDUED: Color32 = SLATE_400; +pub const TEXT_SUBDUED: Color32 = ZINC_300; /// Disabled/non-interactive text -pub const TEXT_DISABLED: Color32 = SLATE_500; +pub const TEXT_DISABLED: Color32 = ZINC_400; // ============================================================================= // SEMANTIC COLORS - Widgets & Borders // ============================================================================= /// Widget inactive background -pub const WIDGET_INACTIVE_BG: Color32 = SLATE_600; +pub const WIDGET_INACTIVE_BG: Color32 = ZINC_600; /// Widget hovered background -pub const WIDGET_HOVERED_BG: Color32 = SLATE_500; +pub const WIDGET_HOVERED_BG: Color32 = ZINC_500; /// Widget active/pressed background -pub const WIDGET_ACTIVE_BG: Color32 = SLATE_400; +pub const WIDGET_ACTIVE_BG: Color32 = ZINC_300; /// Non-interactive widget background -pub const WIDGET_NONINTERACTIVE_BG: Color32 = SLATE_800; +pub const WIDGET_NONINTERACTIVE_BG: Color32 = ZINC_700; /// Border color (use sparingly - prefer no borders) -pub const BORDER_COLOR: Color32 = SLATE_600; +pub const BORDER_COLOR: Color32 = ZINC_500; /// Secondary border color -pub const BORDER_SECONDARY: Color32 = SLATE_500; +pub const BORDER_SECONDARY: Color32 = ZINC_400; // ============================================================================= // LAYOUT CONSTANTS - Heights @@ -168,14 +174,31 @@ pub const PANE_HEADER_HEIGHT: f32 = TITLE_BAR_HEIGHT; /// This is the x position of the separator in headers AND the width of the labels column. pub const LABEL_WIDTH: f32 = 112.0; +// ============================================================================= +// DATA TABLE COLORS +// ============================================================================= + +/// Zebra stripe: even row background (matches panel bg) +pub const TABLE_ROW_EVEN: Color32 = ZINC_800; +/// Zebra stripe: odd row alternating background (between ZINC_800 and ZINC_700) +pub const TABLE_ROW_ODD: Color32 = Color32::from_rgb(51, 51, 56); +/// Table header background +pub const TABLE_HEADER_BG: Color32 = ZINC_700; +/// Table header text color +pub const TABLE_HEADER_TEXT: Color32 = ZINC_200; +/// Table cell text color +pub const TABLE_CELL_TEXT: Color32 = ZINC_100; +/// Index column text color (subdued) +pub const TABLE_INDEX_TEXT: Color32 = ZINC_300; + // ============================================================================= // PANE HEADER COLORS // ============================================================================= /// Pane header background color (same as panel for seamless look) -pub const PANE_HEADER_BACKGROUND_COLOR: Color32 = SLATE_800; +pub const PANE_HEADER_BACKGROUND_COLOR: Color32 = ZINC_700; /// Pane header foreground/text color -pub const PANE_HEADER_FOREGROUND_COLOR: Color32 = SLATE_300; +pub const PANE_HEADER_FOREGROUND_COLOR: Color32 = ZINC_200; pub const PARAMETER_PANEL_WIDTH: f32 = 280.0; pub const PARAMETER_ROW_HEIGHT: f32 = ROW_HEIGHT; @@ -218,6 +241,10 @@ pub const BUTTON_ICON_SIZE: f32 = 16.0; pub const ICON_SIZE_SMALL: f32 = 16.0; /// Scroll bar width pub const SCROLL_BAR_WIDTH: f32 = 8.0; +/// Splitter bar visual thickness (drawn line). +pub const SPLITTER_THICKNESS: f32 = 2.0; +/// Splitter interaction zone height (larger than visual for easy grabbing). +pub const SPLITTER_AFFORDANCE: f32 = 8.0; // ============================================================================= // TYPOGRAPHY @@ -241,34 +268,34 @@ pub const VALUE_TEXT: Color32 = VIOLET_400; pub const VALUE_TEXT_HOVER: Color32 = VIOLET_500; // Background colors -pub const BACKGROUND_COLOR: Color32 = SLATE_800; -pub const HEADER_BACKGROUND: Color32 = SLATE_800; -pub const DARK_BACKGROUND: Color32 = SLATE_900; +pub const BACKGROUND_COLOR: Color32 = ZINC_700; +pub const HEADER_BACKGROUND: Color32 = ZINC_700; +pub const DARK_BACKGROUND: Color32 = ZINC_800; // Text colors pub const TEXT_NORMAL: Color32 = TEXT_DEFAULT; pub const TEXT_BRIGHT: Color32 = TEXT_STRONG; // Port/parameter colors (labels on left are darker) -pub const PORT_LABEL_BACKGROUND: Color32 = SLATE_800; -pub const PORT_VALUE_BACKGROUND: Color32 = SLATE_700; +pub const PORT_LABEL_BACKGROUND: Color32 = ZINC_800; +pub const PORT_VALUE_BACKGROUND: Color32 = ZINC_700; // Tab colors -pub const SELECTED_TAB_BACKGROUND: Color32 = SLATE_700; -pub const UNSELECTED_TAB_BACKGROUND: Color32 = SLATE_800; +pub const SELECTED_TAB_BACKGROUND: Color32 = ZINC_600; +pub const UNSELECTED_TAB_BACKGROUND: Color32 = ZINC_700; // Address bar colors -pub const ADDRESS_BAR_BACKGROUND: Color32 = SLATE_800; -pub const ADDRESS_SEGMENT_HOVER: Color32 = SLATE_600; -pub const ADDRESS_SEPARATOR_COLOR: Color32 = SLATE_500; +pub const ADDRESS_BAR_BACKGROUND: Color32 = ZINC_700; +pub const ADDRESS_SEGMENT_HOVER: Color32 = ZINC_500; +pub const ADDRESS_SEPARATOR_COLOR: Color32 = ZINC_400; // Animation bar colors -pub const ANIMATION_BAR_BACKGROUND: Color32 = SLATE_900; +pub const ANIMATION_BAR_BACKGROUND: Color32 = ZINC_800; // Network view colors -pub const NETWORK_BACKGROUND: Color32 = SLATE_900; -/// Grid lines - subtle contrast against slate-900 background -pub const NETWORK_GRID: Color32 = SLATE_800; +pub const NETWORK_BACKGROUND: Color32 = ZINC_800; +/// Grid lines - subtle contrast against zinc-800 background +pub const NETWORK_GRID: Color32 = ZINC_700; // Network View - Tooltips pub const TOOLTIP_BG: Color32 = SURFACE_ELEVATED; @@ -279,17 +306,17 @@ pub const CONNECTION_HOVER: Color32 = ERROR_RED; // Red indicates deletable pub const PORT_HOVER: Color32 = VIOLET_400; // Accent for interactive // Node body fill colors - muted tints based on output type -// Base: SLATE_600 (69, 85, 108) - all variants stay dark and professional -pub const NODE_BODY_GEOMETRY: Color32 = SLATE_600; // Standard slate -pub const NODE_BODY_INT: Color32 = Color32::from_rgb(65, 78, 108); // Subtle blue tint -pub const NODE_BODY_FLOAT: Color32 = Color32::from_rgb(65, 78, 108); // Subtle blue tint -pub const NODE_BODY_STRING: Color32 = Color32::from_rgb(62, 88, 82); // Subtle green tint -pub const NODE_BODY_BOOLEAN: Color32 = Color32::from_rgb(90, 82, 65); // Subtle amber tint -pub const NODE_BODY_POINT: Color32 = Color32::from_rgb(58, 85, 95); // Subtle cyan tint -pub const NODE_BODY_COLOR: Color32 = Color32::from_rgb(85, 70, 90); // Subtle pink tint -pub const NODE_BODY_LIST: Color32 = Color32::from_rgb(58, 88, 88); // Subtle teal tint -pub const NODE_BODY_DATA: Color32 = Color32::from_rgb(92, 78, 62); // Subtle orange tint -pub const NODE_BODY_DEFAULT: Color32 = SLATE_600; // Fallback +// Base: ZINC_500 (113, 113, 123) - all variants stay dark and professional +pub const NODE_BODY_GEOMETRY: Color32 = ZINC_500; // Standard zinc +pub const NODE_BODY_INT: Color32 = Color32::from_rgb(105, 110, 135); // Subtle blue tint +pub const NODE_BODY_FLOAT: Color32 = Color32::from_rgb(105, 110, 135); // Subtle blue tint +pub const NODE_BODY_STRING: Color32 = Color32::from_rgb(102, 122, 112); // Subtle green tint +pub const NODE_BODY_BOOLEAN: Color32 = Color32::from_rgb(125, 115, 102); // Subtle amber tint +pub const NODE_BODY_POINT: Color32 = Color32::from_rgb(100, 118, 128); // Subtle cyan tint +pub const NODE_BODY_COLOR: Color32 = Color32::from_rgb(120, 106, 125); // Subtle pink tint +pub const NODE_BODY_LIST: Color32 = Color32::from_rgb(100, 122, 120); // Subtle teal tint +pub const NODE_BODY_DATA: Color32 = Color32::from_rgb(128, 114, 102); // Subtle orange tint +pub const NODE_BODY_DEFAULT: Color32 = ZINC_500; // Fallback // Node Category Colors (for node icons/identity) pub const CATEGORY_GEOMETRY: Color32 = Color32::from_rgb(80, 120, 200); @@ -299,16 +326,16 @@ pub const CATEGORY_MATH: Color32 = Color32::from_rgb(120, 200, 80); pub const CATEGORY_LIST: Color32 = Color32::from_rgb(200, 200, 80); pub const CATEGORY_STRING: Color32 = Color32::from_rgb(180, 80, 200); pub const CATEGORY_DATA: Color32 = Color32::from_rgb(80, 200, 200); -pub const CATEGORY_DEFAULT: Color32 = SLATE_500; +pub const CATEGORY_DEFAULT: Color32 = ZINC_400; // Handle Colors (violet-based to match accent) pub const HANDLE_PRIMARY: Color32 = VIOLET_500; // Canvas/Viewer grid (uses alpha, so defined as function) pub fn viewer_grid() -> Color32 { - Color32::from_rgba_unmultiplied(144, 161, 185, 40) // slate-400 with alpha + Color32::from_rgba_unmultiplied(212, 212, 216, 40) // zinc-300 with alpha } -pub const VIEWER_CROSSHAIR: Color32 = SLATE_400; +pub const VIEWER_CROSSHAIR: Color32 = ZINC_300; // Point Type Visualization pub const POINT_LINE_TO: Color32 = Color32::from_rgb(100, 200, 100); @@ -316,8 +343,8 @@ pub const POINT_CURVE_TO: Color32 = Color32::from_rgb(200, 100, 100); pub const POINT_CURVE_DATA: Color32 = Color32::from_rgb(100, 100, 200); // Timeline -pub const TIMELINE_BG: Color32 = SLATE_800; -pub const TIMELINE_MARKER: Color32 = SLATE_600; +pub const TIMELINE_BG: Color32 = ZINC_700; +pub const TIMELINE_MARKER: Color32 = ZINC_500; pub const TIMELINE_PLAYHEAD: Color32 = ERROR_RED; // Port type colors (semantic colors for data types) @@ -327,20 +354,20 @@ pub const PORT_COLOR_STRING: Color32 = Color32::from_rgb(34, 197, 94); // Green pub const PORT_COLOR_BOOLEAN: Color32 = Color32::from_rgb(234, 179, 8); // Yellow pub const PORT_COLOR_POINT: Color32 = Color32::from_rgb(56, 189, 248); // Sky blue pub const PORT_COLOR_COLOR: Color32 = Color32::from_rgb(236, 72, 153); // Pink -pub const PORT_COLOR_GEOMETRY: Color32 = SLATE_600; // Same as node body +pub const PORT_COLOR_GEOMETRY: Color32 = ZINC_500; // Same as node body pub const PORT_COLOR_LIST: Color32 = Color32::from_rgb(20, 184, 166); // Teal pub const PORT_COLOR_DATA: Color32 = Color32::from_rgb(249, 115, 22); // Orange // Node selection dialog colors -pub const DIALOG_BACKGROUND: Color32 = SLATE_800; -pub const DIALOG_BORDER: Color32 = SLATE_600; +pub const DIALOG_BACKGROUND: Color32 = ZINC_700; +pub const DIALOG_BORDER: Color32 = ZINC_500; pub const SELECTED_ITEM: Color32 = SELECTION_BG; -pub const HOVERED_ITEM: Color32 = SLATE_700; +pub const HOVERED_ITEM: Color32 = ZINC_600; // Button colors -pub const BUTTON_NORMAL: Color32 = SLATE_600; -pub const BUTTON_HOVER: Color32 = SLATE_500; -pub const BUTTON_ACTIVE: Color32 = SLATE_400; +pub const BUTTON_NORMAL: Color32 = ZINC_500; +pub const BUTTON_HOVER: Color32 = ZINC_400; +pub const BUTTON_ACTIVE: Color32 = ZINC_300; // ============================================================================= // STYLE CONFIGURATION @@ -373,10 +400,10 @@ pub fn configure_style(ctx: &egui::Context) { FontId::monospace(FONT_SIZE_BASE), ); - // Spacing - generous for a modern, breathable feel + // Spacing - compact for a minimal feel style.spacing.item_spacing = egui::vec2(ITEM_SPACING, ITEM_SPACING); - style.spacing.button_padding = egui::vec2(PADDING_LARGE, PADDING); - style.spacing.menu_margin = egui::Margin::same(MENU_SPACING as i8); + style.spacing.button_padding = egui::vec2(8.0, 4.0); // Compact button padding + style.spacing.menu_margin = egui::Margin::same(2); // Tight menu margins style.spacing.indent = INDENT; style.spacing.scroll = egui::style::ScrollStyle { bar_width: SCROLL_BAR_WIDTH, @@ -387,45 +414,54 @@ pub fn configure_style(ctx: &egui::Context) { // Visuals - Window (sharp corners, subtle border) visuals.window_fill = SURFACE_ELEVATED; - visuals.window_stroke = Stroke::new(1.0, SLATE_600); // Very subtle border + visuals.window_stroke = Stroke::new(1.0, ZINC_500); // Very subtle border visuals.window_corner_radius = CornerRadius::ZERO; // Sharp 90° corners visuals.window_shadow = egui::Shadow::NONE; + // Visuals - Menu/popup (sharp corners, no shadow) + visuals.menu_corner_radius = CornerRadius::ZERO; + visuals.popup_shadow = egui::Shadow::NONE; + // Visuals - Panel (no borders, use background differentiation) visuals.panel_fill = PANEL_BG; - visuals.faint_bg_color = SLATE_800; - visuals.extreme_bg_color = SLATE_950; + visuals.faint_bg_color = ZINC_700; + visuals.extreme_bg_color = ZINC_900; // Visuals - Widgets (sharp corners, minimal borders) - visuals.widgets.noninteractive.bg_fill = WIDGET_NONINTERACTIVE_BG; + visuals.widgets.noninteractive.bg_fill = ZINC_700; + visuals.widgets.noninteractive.weak_bg_fill = ZINC_700; visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT_SUBDUED); visuals.widgets.noninteractive.corner_radius = CornerRadius::ZERO; visuals.widgets.noninteractive.bg_stroke = Stroke::NONE; - visuals.widgets.inactive.bg_fill = WIDGET_INACTIVE_BG; - visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, TEXT_DEFAULT); + visuals.widgets.inactive.bg_fill = ZINC_600; + visuals.widgets.inactive.weak_bg_fill = ZINC_600; + visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, ZINC_100); visuals.widgets.inactive.corner_radius = CornerRadius::ZERO; visuals.widgets.inactive.bg_stroke = Stroke::NONE; - visuals.widgets.hovered.bg_fill = WIDGET_HOVERED_BG; - visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT_STRONG); + visuals.widgets.hovered.bg_fill = ZINC_500; + visuals.widgets.hovered.weak_bg_fill = ZINC_500; + visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, ZINC_50); visuals.widgets.hovered.corner_radius = CornerRadius::ZERO; visuals.widgets.hovered.expansion = 0.0; // No expansion, just color change visuals.widgets.hovered.bg_stroke = Stroke::NONE; - visuals.widgets.active.bg_fill = WIDGET_ACTIVE_BG; - visuals.widgets.active.fg_stroke = Stroke::new(1.0, TEXT_STRONG); + visuals.widgets.active.bg_fill = ZINC_500; + visuals.widgets.active.weak_bg_fill = ZINC_500; + visuals.widgets.active.fg_stroke = Stroke::new(1.0, ZINC_50); visuals.widgets.active.corner_radius = CornerRadius::ZERO; visuals.widgets.active.expansion = 0.0; visuals.widgets.active.bg_stroke = Stroke::NONE; - visuals.widgets.open.bg_fill = WIDGET_ACTIVE_BG; - visuals.widgets.open.fg_stroke = Stroke::new(1.0, TEXT_STRONG); + visuals.widgets.open.bg_fill = ZINC_500; + visuals.widgets.open.weak_bg_fill = ZINC_500; + visuals.widgets.open.fg_stroke = Stroke::new(1.0, ZINC_100); visuals.widgets.open.corner_radius = CornerRadius::ZERO; - // Selection (violet tint, no stroke for cleaner look) + // Selection (violet tint with visible text) visuals.selection.bg_fill = SELECTION_BG; - visuals.selection.stroke = Stroke::NONE; + visuals.selection.stroke = Stroke::new(1.0, TEXT_STRONG); // Separators - almost invisible visuals.widgets.noninteractive.bg_stroke = Stroke::NONE; diff --git a/crates/nodebox-gui/src/timeline.rs b/crates/nodebox-desktop/src/timeline.rs similarity index 100% rename from crates/nodebox-gui/src/timeline.rs rename to crates/nodebox-desktop/src/timeline.rs diff --git a/crates/nodebox-gui/src/vello_convert.rs b/crates/nodebox-desktop/src/vello_convert.rs similarity index 86% rename from crates/nodebox-gui/src/vello_convert.rs rename to crates/nodebox-desktop/src/vello_convert.rs index 92885d04..d5f470ae 100644 --- a/crates/nodebox-gui/src/vello_convert.rs +++ b/crates/nodebox-desktop/src/vello_convert.rs @@ -89,6 +89,33 @@ pub fn contour_to_bezpath(contour: &Contour) -> BezPath { path.line_to(point_to_kurbo(&pp.point)); i += 1; } + PointType::QuadData => { + // Quadratic bezier: QuadData (ctrl), QuadTo (end) + if i + 1 < points.len() { + let ctrl = &points[i]; + let end = &points[i + 1]; + + // Verify the structure is correct + if ctrl.point_type == PointType::QuadData + && end.point_type == PointType::QuadTo + { + path.quad_to( + point_to_kurbo(&ctrl.point), + point_to_kurbo(&end.point), + ); + i += 2; + continue; + } + } + // Fallback: treat as line if structure is invalid + path.line_to(point_to_kurbo(&pp.point)); + i += 1; + } + PointType::QuadTo => { + // Standalone QuadTo without preceding QuadData - treat as line + path.line_to(point_to_kurbo(&pp.point)); + i += 1; + } } } diff --git a/crates/nodebox-gui/src/vello_renderer.rs b/crates/nodebox-desktop/src/vello_renderer.rs similarity index 100% rename from crates/nodebox-gui/src/vello_renderer.rs rename to crates/nodebox-desktop/src/vello_renderer.rs diff --git a/crates/nodebox-gui/src/vello_viewer.rs b/crates/nodebox-desktop/src/vello_viewer.rs similarity index 95% rename from crates/nodebox-gui/src/vello_viewer.rs rename to crates/nodebox-desktop/src/vello_viewer.rs index 512c8659..33b7402d 100644 --- a/crates/nodebox-gui/src/vello_viewer.rs +++ b/crates/nodebox-desktop/src/vello_viewer.rs @@ -110,6 +110,7 @@ struct CacheKey { zoom: i32, // Stored as fixed-point (zoom * 1000) geometry_hash: u64, scale_factor: i32, // pixels_per_point * 100 + bg_color: [u8; 4], // Background color as RGBA bytes } impl CacheKey { @@ -121,6 +122,7 @@ impl CacheKey { zoom: f32, geometry_hash: u64, scale_factor: f32, + background_color: &Color, ) -> Self { CacheKey { width, @@ -130,6 +132,12 @@ impl CacheKey { zoom: (zoom * 1000.0) as i32, geometry_hash, scale_factor: (scale_factor * 100.0) as i32, + bg_color: [ + (background_color.r * 255.0) as u8, + (background_color.g * 255.0) as u8, + (background_color.b * 255.0) as u8, + (background_color.a * 255.0) as u8, + ], } } } @@ -286,6 +294,7 @@ impl VelloViewer { zoom, geometry_hash, scale_factor, + &self.background_color, ); // Check if we need to re-render @@ -420,11 +429,17 @@ mod tests { #[test] fn test_cache_key_equality() { - let key1 = CacheKey::new(100, 100, 0.0, 0.0, 1.0, 12345, 2.0); - let key2 = CacheKey::new(100, 100, 0.0, 0.0, 1.0, 12345, 2.0); - let key3 = CacheKey::new(100, 100, 1.0, 0.0, 1.0, 12345, 2.0); + let white = Color::WHITE; + let key1 = CacheKey::new(100, 100, 0.0, 0.0, 1.0, 12345, 2.0, &white); + let key2 = CacheKey::new(100, 100, 0.0, 0.0, 1.0, 12345, 2.0, &white); + let key3 = CacheKey::new(100, 100, 1.0, 0.0, 1.0, 12345, 2.0, &white); assert_eq!(key1, key2); assert_ne!(key1, key3); + + // Different background color should invalidate cache + let red = Color::rgb(1.0, 0.0, 0.0); + let key4 = CacheKey::new(100, 100, 0.0, 0.0, 1.0, 12345, 2.0, &red); + assert_ne!(key1, key4); } } diff --git a/crates/nodebox-desktop/src/viewer_pane.rs b/crates/nodebox-desktop/src/viewer_pane.rs new file mode 100644 index 00000000..5d093e84 --- /dev/null +++ b/crates/nodebox-desktop/src/viewer_pane.rs @@ -0,0 +1,1932 @@ +//! Tabbed viewer pane with canvas and data views. + +use eframe::egui::{self, Color32, ColorImage, Pos2, Rect, Stroke, TextureHandle, TextureOptions, Vec2}; +use egui_extras::{Column, TableBuilder}; +use nodebox_core::geometry::{Color, Path, PathPoint, Point, PointType}; +use std::collections::HashMap; +use nodebox_core::ops::data::DataValue; +use crate::components; +use crate::eval::NodeOutput; +use crate::handles::{FourPointHandle, HandleSet, HANDLE_COLOR}; +use crate::pan_zoom::PanZoom; +use crate::state::AppState; +use crate::theme; + +#[cfg(feature = "gpu-rendering")] +use crate::vello_viewer::VelloViewer; +#[cfg(feature = "gpu-rendering")] +use std::hash::{Hash, Hasher}; + +/// Re-export or define RenderState type for unified API. +/// When gpu-rendering is enabled, this is egui_wgpu::RenderState. +/// When disabled, we use a unit type placeholder. +#[cfg(feature = "gpu-rendering")] +pub type RenderState = egui_wgpu::RenderState; + +#[cfg(not(feature = "gpu-rendering"))] +pub type RenderState = (); + +/// Result of handle interaction. +#[derive(Clone, Debug)] +pub enum HandleResult { + /// No interaction occurred. + None, + /// A single point changed (for regular handles). + PointChange { param: String, value: Point }, + /// FourPointHandle changed (x, y, width, height). + FourPointChange { x: f64, y: f64, width: f64, height: f64 }, + /// A string parameter changed (e.g., freehand path drawing). + StringChange { param: String, value: String }, +} + +/// State for freehand drawing on the canvas. +struct FreehandState { + /// The parameter name to write to ("path"). + param_name: String, + /// Whether we are currently drawing (mouse is pressed). + is_drawing: bool, + /// The accumulated path string. + path_string: String, + /// Whether the next point starts a new contour (needs "M" prefix). + new_contour: bool, +} + +impl FreehandState { + fn new(param_name: &str, initial_path: &str) -> Self { + Self { + param_name: param_name.to_string(), + is_drawing: false, + path_string: initial_path.to_string(), + new_contour: true, + } + } + + /// Start a new drawing stroke. + fn start_stroke(&mut self) { + self.is_drawing = true; + self.new_contour = true; + } + + /// Add a point to the current stroke. + fn add_point(&mut self, x: f64, y: f64) { + if self.new_contour { + self.path_string.push('M'); + self.new_contour = false; + } else { + self.path_string.push(' '); + } + // Match Java format: "%.2f,%.2f" + use std::fmt::Write; + let _ = write!(self.path_string, "{:.2},{:.2}", x, y); + } + + /// End the current stroke. + fn end_stroke(&mut self) { + self.is_drawing = false; + } +} + +/// Which tab is currently selected in the viewer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ViewerTab { + Viewer, + Data, +} + +/// Which sub-view is selected in the Data tab. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DataViewMode { + Paths, + Points, +} + +/// Cached textures for outlined digit rendering (Houdini-style). +struct DigitCache { + /// Texture handles for digits 0-9. + textures: [Option; 10], + /// Width of each digit texture. + digit_width: f32, + /// Height of each digit texture. + digit_height: f32, +} + +impl DigitCache { + fn new() -> Self { + Self { + textures: Default::default(), + digit_width: 0.0, + digit_height: 0.0, + } + } + + /// Ensure digit textures are created. + fn ensure_initialized(&mut self, ctx: &egui::Context) { + if self.textures[0].is_some() { + return; // Already initialized + } + + // Create outlined digit textures + const FONT_SIZE: f32 = 12.0; + const PADDING: usize = 2; // For outline + + for digit in 0..10 { + let digit_char = char::from_digit(digit as u32, 10).unwrap(); + let image = Self::render_outlined_digit(ctx, digit_char, FONT_SIZE, PADDING); + + if digit == 0 { + self.digit_width = image.width() as f32; + self.digit_height = image.height() as f32; + } + + let texture = ctx.load_texture( + format!("digit_{}", digit), + image, + TextureOptions::LINEAR, + ); + self.textures[digit] = Some(texture); + } + } + + /// Render a single digit with white outline and blue fill. + fn render_outlined_digit(ctx: &egui::Context, digit: char, font_size: f32, padding: usize) -> ColorImage { + // Use egui's font system to get glyph info + let font_id = egui::FontId::proportional(font_size); + + // Get the galley for measuring + let galley = ctx.fonts_mut(|f| { + f.layout_no_wrap(digit.to_string(), font_id.clone(), Color32::WHITE) + }); + + let glyph_width = galley.rect.width().ceil() as usize; + let glyph_height = galley.rect.height().ceil() as usize; + + // Image size with padding for outline + let width = glyph_width + padding * 2 + 2; + let height = glyph_height + padding * 2; + + // Create image buffer + let mut pixels = vec![Color32::TRANSPARENT; width * height]; + + // Render outline (white) by sampling at offsets + let outline_color = Color32::WHITE; + let fill_color = HANDLE_COLOR; + + // Get font texture and UV info for the glyph + // Since we can't easily access raw glyph data, use a simpler approach: + // Render using a pre-defined bitmap font pattern for digits + let bitmap = get_digit_bitmap(digit); + + let scale = (font_size / 8.0).max(1.0) as usize; // Scale factor + let bmp_width = 5 * scale; + let bmp_height = 7 * scale; + + // Center the bitmap in the image + let offset_x = (width - bmp_width) / 2; + let offset_y = (height - bmp_height) / 2; + + // Draw outline first (white, offset in 8 directions) + for dy in -1i32..=1 { + for dx in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + draw_digit_bitmap(&mut pixels, width, &bitmap, scale, + (offset_x as i32 + dx) as usize, + (offset_y as i32 + dy) as usize, + outline_color); + } + } + + // Draw fill (blue) + draw_digit_bitmap(&mut pixels, width, &bitmap, scale, offset_x, offset_y, fill_color); + + ColorImage { + size: [width, height], + pixels, + source_size: egui::Vec2::new(width as f32, height as f32), + } + } + + /// Get texture for a digit. + fn get(&self, digit: usize) -> Option<&TextureHandle> { + self.textures.get(digit).and_then(|t| t.as_ref()) + } +} + +/// 5x7 bitmap font for digits 0-9. +fn get_digit_bitmap(digit: char) -> [u8; 7] { + match digit { + '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], + '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], + '2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111], + '3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110], + '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], + '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], + '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], + '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], + '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], + '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], + _ => [0; 7], + } +} + +/// Draw a digit bitmap to the pixel buffer. +fn draw_digit_bitmap(pixels: &mut [Color32], img_width: usize, bitmap: &[u8; 7], scale: usize, x_off: usize, y_off: usize, color: Color32) { + for (row, bits) in bitmap.iter().enumerate() { + for col in 0..5 { + if (bits >> (4 - col)) & 1 == 1 { + // Draw scaled pixel + for sy in 0..scale { + for sx in 0..scale { + let px = x_off + col * scale + sx; + let py = y_off + row * scale + sy; + if px < img_width && py < pixels.len() / img_width { + pixels[py * img_width + px] = color; + } + } + } + } + } + } +} + +/// The tabbed viewer pane. +pub struct ViewerPane { + /// Currently selected tab. + current_tab: ViewerTab, + /// Whether to show handles. + pub show_handles: bool, + /// Whether to show points. + pub show_points: bool, + /// Whether to show point numbers. + pub show_point_numbers: bool, + /// Whether to show origin crosshair. + pub show_origin: bool, + /// Whether to show the canvas border. + pub show_canvas_border: bool, + /// Pan and zoom state. + pan_zoom: PanZoom, + /// Active handles for the selected node. + handles: Option, + /// FourPointHandle for rect nodes. + four_point_handle: Option, + /// Index of handle being dragged. + dragging_handle: Option, + /// Whether space bar is currently pressed (for panning). + is_space_pressed: bool, + /// Whether we are currently panning with space+drag. + is_panning: bool, + /// Cached digit textures for point numbers. + digit_cache: DigitCache, + /// GPU-accelerated Vello viewer (when gpu-rendering feature is enabled). + #[cfg(feature = "gpu-rendering")] + vello_viewer: VelloViewer, + /// Whether to use GPU rendering (can be toggled at runtime). + #[cfg(feature = "gpu-rendering")] + pub use_gpu_rendering: bool, + /// Current data view mode (paths or points). + data_view_mode: DataViewMode, + /// The user's preferred tab when a Geometry node is rendered. + preferred_geometry_tab: ViewerTab, + /// Whether the Visual tab is available (rendered node outputs Geometry/Point). + visual_tab_available: bool, + /// Freehand drawing state (active when a freehand node is selected). + freehand_state: Option, +} + +impl Default for ViewerPane { + fn default() -> Self { + Self::new() + } +} + +impl ViewerPane { + /// Create a new viewer pane. + pub fn new() -> Self { + Self { + current_tab: ViewerTab::Viewer, + show_handles: true, + show_points: false, + show_point_numbers: false, + show_origin: true, + show_canvas_border: true, + pan_zoom: PanZoom::with_zoom_limits(0.1, 10.0), + handles: None, + four_point_handle: None, + dragging_handle: None, + is_space_pressed: false, + is_panning: false, + digit_cache: DigitCache::new(), + #[cfg(feature = "gpu-rendering")] + vello_viewer: VelloViewer::new(), + #[cfg(feature = "gpu-rendering")] + use_gpu_rendering: true, // Default to GPU rendering when available + data_view_mode: DataViewMode::Points, + preferred_geometry_tab: ViewerTab::Viewer, + visual_tab_available: true, + freehand_state: None, + } + } + + /// Whether the user is currently dragging a handle in the viewer. + pub fn is_dragging(&self) -> bool { + self.dragging_handle.is_some() + || self.four_point_handle.as_ref().is_some_and(|fp| fp.is_dragging()) + || self.freehand_state.as_ref().is_some_and(|fs| fs.is_drawing) + } + + /// Get the current pan offset. + #[allow(dead_code)] + pub fn pan(&self) -> Vec2 { + self.pan_zoom.pan + } + + /// Zoom in by a step. + #[allow(dead_code)] + pub fn zoom_in(&mut self) { + self.pan_zoom.zoom_in(); + } + + /// Zoom out by a step. + #[allow(dead_code)] + pub fn zoom_out(&mut self) { + self.pan_zoom.zoom_out(); + } + + /// Fit the view to show all geometry. + #[allow(dead_code)] + pub fn fit_to_window(&mut self) { + self.pan_zoom.reset(); + } + + /// Reset zoom to 100% (actual size). + pub fn reset_zoom(&mut self) { + self.pan_zoom.reset(); + } + + /// Compute a hash of the geometry for cache invalidation. + #[cfg(feature = "gpu-rendering")] + fn hash_geometry(geometry: &[Path]) -> u64 { + use std::collections::hash_map::DefaultHasher; + let mut hasher = DefaultHasher::new(); + // Hash path count and basic properties + geometry.len().hash(&mut hasher); + for path in geometry { + path.contours.len().hash(&mut hasher); + for contour in &path.contours { + contour.points.len().hash(&mut hasher); + contour.closed.hash(&mut hasher); + // Hash actual point coordinates (critical for cache invalidation!) + for point in &contour.points { + point.point.x.to_bits().hash(&mut hasher); + point.point.y.to_bits().hash(&mut hasher); + std::mem::discriminant(&point.point_type).hash(&mut hasher); + } + } + // Hash fill color + if let Some(fill) = path.fill { + fill.r.to_bits().hash(&mut hasher); + fill.g.to_bits().hash(&mut hasher); + fill.b.to_bits().hash(&mut hasher); + fill.a.to_bits().hash(&mut hasher); + } + // Hash stroke color + if let Some(stroke) = path.stroke { + stroke.r.to_bits().hash(&mut hasher); + stroke.g.to_bits().hash(&mut hasher); + stroke.b.to_bits().hash(&mut hasher); + stroke.a.to_bits().hash(&mut hasher); + } + // Hash stroke width + path.stroke_width.to_bits().hash(&mut hasher); + } + hasher.finish() + } + + /// Get a mutable reference to the handles. + #[allow(dead_code)] + pub fn handles_mut(&mut self) -> &mut Option { + &mut self.handles + } + + /// Set handles. + #[allow(dead_code)] + pub fn set_handles(&mut self, handles: Option) { + self.handles = handles; + } + + /// Show the viewer pane with header tabs and toolbar. + /// Returns any handle interaction result. + /// + /// Pass `render_state` for GPU-accelerated rendering when available. + /// When `gpu-rendering` feature is disabled, pass `None`. + pub fn show(&mut self, ui: &mut egui::Ui, state: &AppState, render_state: Option<&RenderState>) -> HandleResult { + // Auto-switch tab based on whether the rendered node outputs geometry. + // When there's no rendered node at all, keep the current tab as-is + // so the user stays in Viewer mode (showing an empty canvas) if that + // was their preferred mode. + let has_rendered = state.library.root.rendered_child.is_some(); + let is_geometry = state.library.is_rendered_output_geometry(); + let was_available = self.visual_tab_available; + + if !has_rendered { + // No rendered node: keep current tab, but mark visual as available + // so the Viewer tab remains selectable (shows empty canvas). + self.visual_tab_available = true; + } else if is_geometry && !was_available { + // Switching back to geometry: restore preferred tab + self.visual_tab_available = true; + self.current_tab = self.preferred_geometry_tab; + } else if !is_geometry && was_available { + // Switching to non-geometry: save preference, force Data + self.preferred_geometry_tab = self.current_tab; + self.visual_tab_available = false; + self.current_tab = ViewerTab::Data; + } else if !is_geometry { + // Staying on non-geometry: keep Data forced + self.current_tab = ViewerTab::Data; + } + + // Remove spacing so content is snug against header + ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); + + // Draw header with "VIEWER" title and separator + let (header_rect, mut x) = components::draw_pane_header_with_title(ui, "Viewer"); + + // Segmented control for Visual/Data toggle + let selected_index = if self.current_tab == ViewerTab::Viewer { 0 } else { 1 }; + let disabled = if self.visual_tab_available { None } else { Some(0) }; + let (clicked_index, new_x) = components::header_segmented_control( + ui, + header_rect, + x, + ["Visual", "Data"], + selected_index, + disabled, + ); + if let Some(index) = clicked_index { + self.current_tab = if index == 0 { ViewerTab::Viewer } else { ViewerTab::Data }; + if self.visual_tab_available { + self.preferred_geometry_tab = self.current_tab; + } + } + x = new_x + theme::PADDING_XL; // 16px spacing after segmented control + + if self.current_tab == ViewerTab::Viewer { + // Visual tab: show toggle buttons for visual options + let (clicked, new_x) = components::header_tab_button( + ui, + header_rect, + x, + "Handles", + self.show_handles, + ); + if clicked { + self.show_handles = !self.show_handles; + } + x = new_x; + + let (clicked, new_x) = components::header_tab_button( + ui, + header_rect, + x, + "Points", + self.show_points, + ); + if clicked { + self.show_points = !self.show_points; + } + x = new_x; + + let (clicked, new_x) = components::header_tab_button( + ui, + header_rect, + x, + "Pt#", + self.show_point_numbers, + ); + if clicked { + self.show_point_numbers = !self.show_point_numbers; + } + x = new_x; + + let (clicked, new_x) = components::header_tab_button( + ui, + header_rect, + x, + "Origin", + self.show_origin, + ); + if clicked { + self.show_origin = !self.show_origin; + } + x = new_x; + + let (clicked, new_x) = components::header_tab_button( + ui, + header_rect, + x, + "Canvas", + self.show_canvas_border, + ); + if clicked { + self.show_canvas_border = !self.show_canvas_border; + } + x = new_x; + } else { + // Data tab: show data view mode selector + if state.node_output.is_geometry() { + // Geometry output: Paths/Points segmented control + let selected_index = if self.data_view_mode == DataViewMode::Paths { 0 } else { 1 }; + let (clicked_index, new_x) = components::header_segmented_control( + ui, + header_rect, + x, + ["Paths", "Points"], + selected_index, + None, + ); + if let Some(index) = clicked_index { + self.data_view_mode = if index == 0 { DataViewMode::Paths } else { DataViewMode::Points }; + } + x = new_x; + } else { + // Non-geometry output: show type label + let type_label = { + let label = state.node_output.type_label(); + let mut s = label.to_string(); + if let Some(first) = s.get_mut(0..1) { + first.make_ascii_uppercase(); + } + s + }; + ui.painter().text( + egui::pos2(x + 6.0, header_rect.center().y), + egui::Align2::LEFT_CENTER, + &type_label, + egui::FontId::proportional(11.0), + theme::TEXT_SUBDUED, + ); + // Approximate width for spacing + x += 60.0; + } + } + + // Zoom controls on the right side: [-] [100%] [+] + let zoom_controls_width = 24.0 + 48.0 + 24.0 + theme::PADDING; // minus + percent + plus + right padding + let spacer_width = header_rect.right() - x - zoom_controls_width; + x += spacer_width.max(0.0); + + // Zoom out button (-) + let (clicked, new_x) = components::header_icon_button(ui, header_rect, x, "−"); + if clicked { + self.pan_zoom.zoom_out(); + } + x = new_x; + + // Zoom percentage DragValue + let (new_zoom, new_x) = components::header_zoom_control(ui, header_rect, x, self.pan_zoom.zoom); + if let Some(zoom) = new_zoom { + self.pan_zoom.zoom = zoom.clamp(0.1, 10.0); + } + x = new_x; + + // Zoom in button (+) + let (clicked, _) = components::header_icon_button(ui, header_rect, x, "+"); + if clicked { + self.pan_zoom.zoom_in(); + } + + // Content area (directly after header, no extra spacing) + match self.current_tab { + ViewerTab::Viewer => self.show_canvas(ui, state, render_state), + ViewerTab::Data => { + self.show_data_view(ui, state); + HandleResult::None + } + } + } + + /// Show the canvas viewer. + /// Uses GPU rendering when available (gpu-rendering feature + valid render_state + use_gpu_rendering enabled). + /// Falls back to CPU rendering otherwise. + fn show_canvas(&mut self, ui: &mut egui::Ui, state: &AppState, render_state: Option<&RenderState>) -> HandleResult { + use crate::handles::{screen_to_world, FourPointDragState}; + + // Initialize digit cache if needed + self.digit_cache.ensure_initialized(ui.ctx()); + + let (response, painter) = + ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); + + let rect = response.rect; + let center = rect.center().to_vec2(); + + // Handle zoom with scroll wheel, centered on mouse position + self.pan_zoom.handle_scroll_zoom(rect, ui, center); + + // Track space bar state for Photoshop-style panning + if ui.input(|i| i.key_pressed(egui::Key::Space)) { + self.is_space_pressed = true; + } + if ui.input(|i| i.key_released(egui::Key::Space)) { + self.is_space_pressed = false; + self.is_panning = false; + } + + // Handle panning with space+drag, middle mouse button, or right drag + let is_panning = self.is_space_pressed && response.dragged_by(egui::PointerButton::Primary); + if is_panning { + self.pan_zoom.pan += response.drag_delta(); + self.is_panning = true; + } + self.pan_zoom.handle_drag_pan(&response, egui::PointerButton::Middle); + self.pan_zoom.handle_drag_pan(&response, egui::PointerButton::Secondary); + + // Change cursor when space is held (panning mode) + if self.is_space_pressed && response.hovered() { + if self.is_panning { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + } + + // Draw background + let bg_color = egui::Color32::from_rgb( + (state.background_color.r * 255.0) as u8, + (state.background_color.g * 255.0) as u8, + (state.background_color.b * 255.0) as u8, + ); + painter.rect_filled(rect, 0.0, bg_color); + + // Draw a subtle grid + self.draw_grid(&painter, rect); + + // Draw all geometry (using GPU or CPU rendering) + self.render_geometry(ui, &painter, state, render_state, rect, center); + + // Draw canvas border (uses document width/height) + // Drawn after geometry so it's visible on top of the Vello GPU texture. + if self.show_canvas_border { + self.draw_canvas_border(&painter, center, state.library.width(), state.library.height()); + } + + // Draw origin crosshair + if self.show_origin { + let origin = self.pan_zoom.world_to_screen(Pos2::ZERO, center); + if rect.contains(origin) { + let crosshair_size = 10.0; + painter.line_segment( + [ + origin - Vec2::new(crosshair_size, 0.0), + origin + Vec2::new(crosshair_size, 0.0), + ], + Stroke::new(1.0, theme::VIEWER_CROSSHAIR), + ); + painter.line_segment( + [ + origin - Vec2::new(0.0, crosshair_size), + origin + Vec2::new(0.0, crosshair_size), + ], + Stroke::new(1.0, theme::VIEWER_CROSSHAIR), + ); + } + } + + // Draw and handle interactive handles + if self.show_handles { + if let Some(ref handles) = self.handles { + handles.draw(&painter, self.pan_zoom.zoom, self.pan_zoom.pan, center); + } + if let Some(ref handle) = self.four_point_handle { + handle.draw(&painter, self.pan_zoom.zoom, self.pan_zoom.pan, center); + } + } + + // Draw point numbers on top of everything (including handles) + if self.show_point_numbers { + let mut point_index = 0usize; + for path in &state.geometry { + point_index = self.draw_point_numbers(&painter, path, center, point_index); + } + } + + // Handle interactions (only if not panning) + if !self.is_space_pressed && self.show_handles { + let mouse_pos = ui.input(|i| i.pointer.hover_pos()); + + // Handle freehand drawing (takes priority when freehand node is selected) + if self.freehand_state.is_some() { + // Draw cursor indicator (5px radius circle, like Java FreehandHandle) + if let Some(pos) = mouse_pos { + if response.hovered() { + painter.circle_stroke( + pos, + 5.0, + Stroke::new(1.0, Color32::from_gray(128)), + ); + } + } + + // Mouse pressed: start new contour + if response.drag_started_by(egui::PointerButton::Primary) { + let freehand = self.freehand_state.as_mut().unwrap(); + freehand.start_stroke(); + if let Some(pos) = mouse_pos { + let world = screen_to_world(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); + freehand.add_point(world.x, world.y); + } + return HandleResult::StringChange { + param: self.freehand_state.as_ref().unwrap().param_name.clone(), + value: self.freehand_state.as_ref().unwrap().path_string.clone(), + }; + } + + // Mouse dragged: add points to current contour + if self.freehand_state.as_ref().unwrap().is_drawing + && response.dragged_by(egui::PointerButton::Primary) + { + if let Some(pos) = mouse_pos { + let freehand = self.freehand_state.as_mut().unwrap(); + let world = screen_to_world(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); + freehand.add_point(world.x, world.y); + } + return HandleResult::StringChange { + param: self.freehand_state.as_ref().unwrap().param_name.clone(), + value: self.freehand_state.as_ref().unwrap().path_string.clone(), + }; + } + + // Mouse released: end stroke + if self.freehand_state.as_ref().unwrap().is_drawing + && response.drag_stopped_by(egui::PointerButton::Primary) + { + self.freehand_state.as_mut().unwrap().end_stroke(); + return HandleResult::StringChange { + param: self.freehand_state.as_ref().unwrap().param_name.clone(), + value: self.freehand_state.as_ref().unwrap().path_string.clone(), + }; + } + + // Change cursor to crosshair when hovering over canvas with freehand active + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + + return HandleResult::None; + } + + // Handle FourPointHandle first (takes priority) + if let Some(ref mut four_point) = self.four_point_handle { + // Check for drag start + if response.drag_started_by(egui::PointerButton::Primary) { + if let Some(pos) = mouse_pos { + if let Some(hit_state) = four_point.hit_test(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center) { + let world_pos = screen_to_world(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); + four_point.start_drag(hit_state, world_pos); + } + } + } + + // Handle dragging + if four_point.is_dragging() { + if response.drag_stopped_by(egui::PointerButton::Primary) { + // Drag ended - return final values + let (x, y, width, height) = four_point.end_drag(); + return HandleResult::FourPointChange { x, y, width, height }; + } else if response.dragged_by(egui::PointerButton::Primary) { + // Still dragging - update and return current values for live preview + if let Some(pos) = mouse_pos { + let world_pos = screen_to_world(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); + four_point.update_drag(world_pos); + } + // Return current values to trigger re-render + return HandleResult::FourPointChange { + x: four_point.center.x, + y: four_point.center.y, + width: four_point.width, + height: four_point.height, + }; + } + } + + // If FourPointHandle is dragging, don't process regular handles + if four_point.drag_state != FourPointDragState::None { + return HandleResult::None; + } + } + + // Check for regular handle dragging + if let Some(ref mut handles) = self.handles { + // Check for drag start + if response.drag_started_by(egui::PointerButton::Primary) { + if let Some(pos) = mouse_pos { + if let Some(idx) = handles.hit_test(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center) { + self.dragging_handle = Some(idx); + if let Some(handle) = handles.handles_mut().get_mut(idx) { + handle.dragging = true; + } + } + } + } + + // Handle dragging + if let Some(idx) = self.dragging_handle { + if response.drag_stopped_by(egui::PointerButton::Primary) { + // Drag ended + if let Some(handle) = handles.handles_mut().get_mut(idx) { + handle.dragging = false; + let param_name = handle.param_name.clone(); + let position = handle.position; + self.dragging_handle = None; + return HandleResult::PointChange { param: param_name, value: position }; + } + self.dragging_handle = None; + } else if response.dragged_by(egui::PointerButton::Primary) { + // Still dragging - update and return current values for live preview + if let Some(pos) = mouse_pos { + handles.update_handle_position(idx, pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); + } + // Return current values to trigger re-render + if let Some(handle) = handles.handles().get(idx) { + return HandleResult::PointChange { + param: handle.param_name.clone(), + value: handle.position, + }; + } + } + } + } + } + + HandleResult::None + } + + /// Render geometry using GPU when available, falling back to CPU. + #[cfg(feature = "gpu-rendering")] + fn render_geometry( + &mut self, + ui: &mut egui::Ui, + painter: &egui::Painter, + state: &AppState, + render_state: Option<&RenderState>, + rect: Rect, + center: Vec2, + ) { + let use_gpu = render_state.is_some() + && self.use_gpu_rendering + && self.vello_viewer.is_available(); + + if use_gpu { + let render_state = render_state.unwrap(); + + // Compute geometry hash for cache invalidation + let geometry_hash = Self::hash_geometry(&state.geometry); + + // Set background color (Vello will render the background) + self.vello_viewer.set_background_color(state.background_color); + + // Render with Vello using shared wgpu device + self.vello_viewer.render( + render_state, + ui, + &state.geometry, + self.pan_zoom.pan, + self.pan_zoom.zoom, + rect, + geometry_hash, + ); + + // Draw points overlay if enabled or if output is from a Point-type node + if self.show_points || state.library.is_rendered_output_point() { + for path in &state.geometry { + self.draw_points(painter, path, center); + } + } + } else { + self.render_geometry_cpu(painter, state, center); + } + } + + /// Render geometry using CPU (when gpu-rendering feature is disabled). + #[cfg(not(feature = "gpu-rendering"))] + fn render_geometry( + &mut self, + _ui: &mut egui::Ui, + painter: &egui::Painter, + state: &AppState, + _render_state: Option<&RenderState>, + _rect: Rect, + center: Vec2, + ) { + self.render_geometry_cpu(painter, state, center); + } + + /// CPU-based geometry rendering (used as fallback or when GPU is unavailable). + fn render_geometry_cpu(&self, painter: &egui::Painter, state: &AppState, center: Vec2) { + for path in &state.geometry { + self.draw_path(painter, path, center); + + if self.show_points || state.library.is_rendered_output_point() { + self.draw_points(painter, path, center); + } + } + } + + /// Show the data view with spreadsheet table. + fn show_data_view(&mut self, ui: &mut egui::Ui, state: &AppState) { + if state.node_output.is_geometry() { + // Geometry output: show Paths or Points table + if state.geometry.is_empty() { + Self::show_data_empty(ui); + return; + } + + match self.data_view_mode { + DataViewMode::Paths => Self::show_paths_table(ui, state), + DataViewMode::Points => Self::show_points_table(ui, state), + } + } else if state.node_output.is_data_rows() { + // Data rows output: show multi-column data table + if state.node_output.item_count() == 0 { + Self::show_data_empty(ui); + } else { + Self::show_data_rows_table(ui, &state.node_output); + } + } else { + // Non-geometry output: show values table + if state.node_output.item_count() == 0 { + Self::show_data_empty(ui); + } else { + Self::show_values_table(ui, &state.node_output); + } + } + } + + /// Show a table of non-geometry values. + fn show_values_table(ui: &mut egui::Ui, output: &crate::eval::NodeOutput) { + let text_height = theme::ROW_HEIGHT; + let values = output.to_display_strings(); + let is_color = output.is_color(); + let type_label = output.type_label(); + + let mut table = TableBuilder::new(ui) + .striped(false) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .min_scrolled_height(0.0) + .max_scroll_height(f32::INFINITY) + .column(Column::exact(60.0)); // Index + if is_color { + table = table.column(Column::exact(24.0)); // Swatch + } + table = table.column(Column::remainder().at_least(100.0).clip(true)); // Value + + // Capitalize first letter of type label for header + let value_header = { + let mut s = type_label.to_string(); + if let Some(first) = s.get_mut(0..1) { + first.make_ascii_uppercase(); + } + s + }; + + table + .header(theme::TABLE_HEADER_HEIGHT, |mut header| { + header.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::TABLE_HEADER_BG); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Index") + .color(theme::TABLE_HEADER_TEXT) + .size(11.0), + ); + }); + if is_color { + header.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::TABLE_HEADER_BG); + }); + } + header.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::TABLE_HEADER_BG); + ui.add_space(8.0); + ui.label( + egui::RichText::new(&value_header) + .color(theme::TABLE_HEADER_TEXT) + .size(11.0), + ); + }); + }) + .body(|body| { + body.rows(text_height, values.len(), |mut row| { + let row_index = row.index(); + let row_bg = if row_index % 2 == 0 { + theme::TABLE_ROW_EVEN + } else { + theme::TABLE_ROW_ODD + }; + + // Index + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", row_index)) + .color(theme::TABLE_INDEX_TEXT) + .size(11.0), + ); + }); + }); + + // Swatch (color types only) + if is_color { + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + if let Some(c) = output.color_at(row_index) { + let swatch_size = 14.0; + let rect = ui.max_rect(); + let swatch_rect = egui::Rect::from_min_size( + egui::pos2( + rect.center().x - swatch_size / 2.0, + rect.center().y - swatch_size / 2.0, + ), + egui::vec2(swatch_size, swatch_size), + ); + let egui_color = Color32::from_rgba_unmultiplied( + (c.r * 255.0) as u8, + (c.g * 255.0) as u8, + (c.b * 255.0) as u8, + (c.a * 255.0) as u8, + ); + if c.a < 1.0 { + ui.painter().rect_filled(swatch_rect, 0.0, Color32::WHITE); + } + ui.painter().rect_filled(swatch_rect, 0.0, egui_color); + ui.painter().rect_stroke( + swatch_rect, + 0.0, + Stroke::new(1.0, theme::ZINC_500), + egui::StrokeKind::Inside, + ); + } + }); + } + + // Value + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.add_space(8.0); + ui.label( + egui::RichText::new(&values[row_index]) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + }); + }); + } + + /// Show a multi-column table for DataRow/DataRows output. + fn show_data_rows_table(ui: &mut egui::Ui, output: &NodeOutput) { + let text_height = theme::ROW_HEIGHT; + + // Collect all rows into a reference list + let rows: Vec<&HashMap> = match output { + NodeOutput::DataRows(rs) => rs.iter().collect(), + NodeOutput::DataRow(r) => vec![r], + _ => return, + }; + + if rows.is_empty() { + return; + } + + // Determine column headers: collect all keys across all rows, sorted for stable display + let mut column_set = std::collections::BTreeSet::new(); + for row in &rows { + for key in row.keys() { + column_set.insert(key.clone()); + } + } + let columns: Vec = column_set.into_iter().collect(); + + if columns.is_empty() { + return; + } + + // Build table with Index + one column per data key + let mut table = TableBuilder::new(ui) + .striped(false) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .min_scrolled_height(0.0) + .max_scroll_height(f32::INFINITY) + .column(Column::exact(60.0)); // Index + + for _ in &columns { + table = table.column(Column::initial(120.0).at_least(60.0).clip(true)); + } + + table + .header(theme::TABLE_HEADER_HEIGHT, |mut header| { + // Index header + header.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::TABLE_HEADER_BG); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Index") + .color(theme::TABLE_HEADER_TEXT) + .size(11.0), + ); + }); + // Data column headers + for col_name in &columns { + header.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::TABLE_HEADER_BG); + ui.add_space(8.0); + ui.label( + egui::RichText::new(col_name) + .color(theme::TABLE_HEADER_TEXT) + .size(11.0), + ); + }); + } + }) + .body(|body| { + body.rows(text_height, rows.len(), |mut row| { + let row_index = row.index(); + let data_row = rows[row_index]; + let row_bg = if row_index % 2 == 0 { + theme::TABLE_ROW_EVEN + } else { + theme::TABLE_ROW_ODD + }; + + // Index column + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", row_index)) + .color(theme::TABLE_INDEX_TEXT) + .size(11.0), + ); + }); + }); + + // Data columns + for col_name in &columns { + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.add_space(8.0); + let cell_text = match data_row.get(col_name) { + Some(val) => val.as_string(), + None => String::new(), + }; + ui.label( + egui::RichText::new(cell_text) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + } + }); + }); + } + + /// Show empty state when no data is available. + fn show_data_empty(ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.add_space(50.0); + ui.label( + egui::RichText::new("No data to display") + .color(theme::TEXT_DISABLED) + .size(14.0), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Render a node to see its data here.") + .color(theme::TEXT_DISABLED) + .size(11.0), + ); + }); + } + + /// Show the path-level data table. + fn show_paths_table(ui: &mut egui::Ui, state: &AppState) { + let text_height = theme::ROW_HEIGHT; + + let table = TableBuilder::new(ui) + .striped(false) // Custom zebra striping + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .min_scrolled_height(0.0) + .max_scroll_height(f32::INFINITY) + .column(Column::exact(60.0)) // Index + .column(Column::initial(120.0).at_least(80.0)) // Fill + .column(Column::initial(120.0).at_least(80.0)) // Stroke + .column(Column::initial(90.0).at_least(60.0)) // Stroke Width + .column(Column::initial(70.0).at_least(50.0)) // Contours + .column(Column::initial(70.0).at_least(50.0).clip(true)); // Points + + table + .header(theme::TABLE_HEADER_HEIGHT, |mut header| { + let headers = ["Index", "Fill", "Stroke", "Stroke Width", "Contours", "Points"]; + for h in headers { + header.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::TABLE_HEADER_BG); + ui.add_space(8.0); + ui.label( + egui::RichText::new(h) + .color(theme::TABLE_HEADER_TEXT) + .size(11.0), + ); + }); + } + }) + .body(|body| { + body.rows(text_height, state.geometry.len(), |mut row| { + let row_index = row.index(); + let path = &state.geometry[row_index]; + let row_bg = if row_index % 2 == 0 { + theme::TABLE_ROW_EVEN + } else { + theme::TABLE_ROW_ODD + }; + + // Index + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", row_index)) + .color(theme::TABLE_INDEX_TEXT) + .size(11.0), + ); + }); + }); + + // Fill + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + Self::draw_color_cell(ui, path.fill); + }); + + // Stroke + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + Self::draw_color_cell(ui, path.stroke); + }); + + // Stroke Width + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{:.2}", path.stroke_width)) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + }); + + // Contours + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", path.contours.len())) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + }); + + // Points + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", path.point_count())) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + }); + }); + }); + } + + /// Show the point-level data table. + fn show_points_table(ui: &mut egui::Ui, state: &AppState) { + let text_height = theme::ROW_HEIGHT; + + // Build flat list of (path_idx, contour_idx, point) + let flat_points: Vec<(usize, usize, &PathPoint)> = state.geometry.iter() + .enumerate() + .flat_map(|(pi, path)| { + path.contours.iter().enumerate().flat_map(move |(ci, contour)| { + contour.points.iter().map(move |pp| (pi, ci, pp)) + }) + }) + .collect(); + + let total_rows = flat_points.len(); + + let table = TableBuilder::new(ui) + .striped(false) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .min_scrolled_height(0.0) + .max_scroll_height(f32::INFINITY) + .column(Column::exact(60.0)) // Index + .column(Column::initial(100.0).at_least(70.0)) // X + .column(Column::initial(100.0).at_least(70.0)) // Y + .column(Column::initial(80.0).at_least(60.0)) // Type + .column(Column::initial(50.0).at_least(40.0)) // Path + .column(Column::initial(60.0).at_least(40.0).clip(true)); // Contour + + table + .header(theme::TABLE_HEADER_HEIGHT, |mut header| { + let headers = ["Index", "X", "Y", "Type", "Path", "Contour"]; + for h in headers { + header.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::TABLE_HEADER_BG); + ui.add_space(8.0); + ui.label( + egui::RichText::new(h) + .color(theme::TABLE_HEADER_TEXT) + .size(11.0), + ); + }); + } + }) + .body(|body| { + body.rows(text_height, total_rows, |mut row| { + let row_index = row.index(); + let (path_idx, contour_idx, pp) = flat_points[row_index]; + let row_bg = if row_index % 2 == 0 { + theme::TABLE_ROW_EVEN + } else { + theme::TABLE_ROW_ODD + }; + + // Index + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", row_index)) + .color(theme::TABLE_INDEX_TEXT) + .size(11.0), + ); + }); + }); + + // X + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{:.2}", pp.point.x)) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + }); + + // Y + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{:.2}", pp.point.y)) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + }); + + // Type + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.label( + egui::RichText::new(Self::point_type_label(pp.point_type)) + .color(theme::TABLE_CELL_TEXT) + .size(11.0), + ); + }); + + // Path + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", path_idx)) + .color(theme::TABLE_INDEX_TEXT) + .size(11.0), + ); + }); + }); + + // Contour + row.col(|ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, row_bg); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("{}", contour_idx)) + .color(theme::TABLE_INDEX_TEXT) + .size(11.0), + ); + }); + }); + }); + }); + } + + /// Display label for a PointType. + fn point_type_label(pt: PointType) -> &'static str { + match pt { + PointType::LineTo => "Line", + PointType::CurveTo => "Curve", + PointType::CurveData => "CurveCtl", + PointType::QuadTo => "Quad", + PointType::QuadData => "QuadCtl", + } + } + + /// Draw a color cell with swatch + hex text, or "--" for None. + fn draw_color_cell(ui: &mut egui::Ui, color: Option) { + match color { + Some(c) => { + let swatch_size = 12.0; + let rect = ui.available_rect_before_wrap(); + + // Draw color swatch + let swatch_rect = egui::Rect::from_min_size( + egui::pos2( + rect.left() + 4.0, + rect.center().y - swatch_size / 2.0, + ), + egui::vec2(swatch_size, swatch_size), + ); + let egui_color = Color32::from_rgba_unmultiplied( + (c.r * 255.0) as u8, + (c.g * 255.0) as u8, + (c.b * 255.0) as u8, + (c.a * 255.0) as u8, + ); + + // Checkerboard background for transparency + if c.a < 1.0 { + ui.painter().rect_filled(swatch_rect, 0.0, Color32::WHITE); + } + ui.painter().rect_filled(swatch_rect, 0.0, egui_color); + ui.painter().rect_stroke( + swatch_rect, + 0.0, + Stroke::new(1.0, theme::ZINC_500), + egui::StrokeKind::Inside, + ); + + // Hex text after swatch + let hex = c.to_hex(); + ui.painter().text( + egui::pos2(swatch_rect.right() + 6.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &hex, + egui::FontId::proportional(11.0), + theme::TABLE_CELL_TEXT, + ); + // Allocate space for layout + ui.allocate_exact_size( + egui::vec2(swatch_size + 6.0 + 80.0, swatch_size), + egui::Sense::hover(), + ); + } + None => { + ui.label( + egui::RichText::new("--") + .color(theme::TEXT_DISABLED) + .size(11.0), + ); + } + } + } + + /// Draw the canvas border (document bounds). + /// The border is drawn in screen space (constant 1px line width regardless of zoom). + fn draw_canvas_border(&self, painter: &egui::Painter, center: Vec2, width: f64, height: f64) { + // Canvas is centered at origin, so bounds are from -width/2 to +width/2 + let half_width = width as f32 / 2.0; + let half_height = height as f32 / 2.0; + + let top_left = Pos2::new(-half_width, -half_height); + let bottom_right = Pos2::new(half_width, half_height); + + let screen_top_left = self.pan_zoom.world_to_screen(top_left, center); + let screen_bottom_right = self.pan_zoom.world_to_screen(bottom_right, center); + + let canvas_rect = Rect::from_min_max(screen_top_left, screen_bottom_right); + + // Draw border with constant 1px line width (screen space) + let border_color = Color32::from_rgba_unmultiplied(128, 128, 128, 180); + painter.rect_stroke(canvas_rect, 0.0, Stroke::new(1.0, border_color), egui::StrokeKind::Inside); + } + + /// Draw a background grid. + fn draw_grid(&self, painter: &egui::Painter, rect: Rect) { + let grid_size = 50.0 * self.pan_zoom.zoom; + let grid_color = theme::viewer_grid(); + + let center = rect.center().to_vec2(); + let origin = self.pan_zoom.pan + center; + + // Calculate grid offset + let offset_x = origin.x % grid_size; + let offset_y = origin.y % grid_size; + + // Vertical lines + let mut x = rect.left() + offset_x; + while x < rect.right() { + painter.line_segment( + [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], + Stroke::new(1.0, grid_color), + ); + x += grid_size; + } + + // Horizontal lines + let mut y = rect.top() + offset_y; + while y < rect.bottom() { + painter.line_segment( + [Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)], + Stroke::new(1.0, grid_color), + ); + y += grid_size; + } + } + + /// Draw path points. + fn draw_points(&self, painter: &egui::Painter, path: &Path, center: Vec2) { + for contour in &path.contours { + for pp in contour.points.iter() { + let world_pt = Pos2::new(pp.point.x as f32, pp.point.y as f32); + let screen_pt = self.pan_zoom.world_to_screen(world_pt, center); + + // Draw point marker + let color = match pp.point_type { + PointType::LineTo => theme::POINT_LINE_TO, + PointType::CurveTo | PointType::QuadTo => theme::POINT_CURVE_TO, + PointType::CurveData | PointType::QuadData => theme::POINT_CURVE_DATA, + }; + painter.circle_filled(screen_pt, 3.0, color); + } + } + } + + /// Draw point numbers using cached outlined digit textures (Houdini-style: bottom-right of point). + /// Returns the next point index to use (for tracking across multiple paths). + fn draw_point_numbers(&self, painter: &egui::Painter, path: &Path, center: Vec2, start_index: usize) -> usize { + // Tight spacing between digits (characters are ~7px wide in the texture) + let digit_spacing = 7.0; + let mut point_index = start_index; + + for contour in &path.contours { + for pp in contour.points.iter() { + let world_pt = Pos2::new(pp.point.x as f32, pp.point.y as f32); + let screen_pt = self.pan_zoom.world_to_screen(world_pt, center); + + // Position to the bottom-right of the point (like Houdini) + let mut x = screen_pt.x + 3.0; + let y = screen_pt.y + 2.0; + + // Draw each digit of the number + let num_str = point_index.to_string(); + for ch in num_str.chars() { + if let Some(digit) = ch.to_digit(10) { + if let Some(texture) = self.digit_cache.get(digit as usize) { + let rect = Rect::from_min_size( + Pos2::new(x, y), + Vec2::new(self.digit_cache.digit_width, self.digit_cache.digit_height), + ); + painter.image( + texture.id(), + rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + x += digit_spacing; + } + } + } + point_index += 1; + } + } + point_index + } + + /// Draw a path on the canvas. + fn draw_path(&self, painter: &egui::Painter, path: &Path, center: Vec2) { + for contour in &path.contours { + if contour.points.is_empty() { + continue; + } + + // Build the path points + let mut egui_points: Vec = Vec::new(); + let mut i = 0; + + while i < contour.points.len() { + let pp = &contour.points[i]; + let world_pt = Pos2::new(pp.point.x as f32, pp.point.y as f32); + let screen_pt = self.pan_zoom.world_to_screen(world_pt, center); + + match pp.point_type { + PointType::LineTo => { + egui_points.push(screen_pt); + i += 1; + } + PointType::CurveData => { + // CurveData is a control point - look ahead for the full cubic bezier + // Structure: CurveData (ctrl1), CurveData (ctrl2), CurveTo (end) + if i + 2 < contour.points.len() { + let ctrl1 = &contour.points[i]; + let ctrl2 = &contour.points[i + 1]; + let end = &contour.points[i + 2]; + + // Get start point (last point in egui_points, or first point of contour) + let start = egui_points.last().copied().unwrap_or(screen_pt); + + let c1 = self.world_to_screen(ctrl1.point, center); + let c2 = self.world_to_screen(ctrl2.point, center); + let e = self.world_to_screen(end.point, center); + + // Sample the cubic bezier + for t in 1..=10 { + let t = t as f32 / 10.0; + let pt = cubic_bezier(start, c1, c2, e, t); + egui_points.push(pt); + } + + i += 3; // Skip ctrl1, ctrl2, end + } else { + i += 1; + } + } + PointType::CurveTo => { + // Standalone CurveTo without preceding CurveData - treat as line + egui_points.push(screen_pt); + i += 1; + } + PointType::QuadData => { + // QuadData is a control point - look ahead for the full quadratic bezier + // Structure: QuadData (ctrl), QuadTo (end) + if i + 1 < contour.points.len() { + let ctrl = &contour.points[i]; + let end = &contour.points[i + 1]; + + // Get start point (last point in egui_points, or first point of contour) + let start = egui_points.last().copied().unwrap_or(screen_pt); + + let c = self.world_to_screen(ctrl.point, center); + let e = self.world_to_screen(end.point, center); + + // Sample the quadratic bezier + for t in 1..=10 { + let t = t as f32 / 10.0; + let pt = quadratic_bezier(start, c, e, t); + egui_points.push(pt); + } + + i += 2; // Skip ctrl, end + } else { + i += 1; + } + } + PointType::QuadTo => { + // Standalone QuadTo without preceding QuadData - treat as line + egui_points.push(screen_pt); + i += 1; + } + } + } + + if egui_points.len() < 2 { + continue; + } + + // Close the path if needed + if contour.closed && !egui_points.is_empty() { + egui_points.push(egui_points[0]); + } + + // Draw fill + if let Some(fill) = path.fill { + let fill_color = color_to_egui(fill); + if egui_points.len() >= 3 { + painter.add(egui::Shape::convex_polygon( + egui_points.clone(), + fill_color, + Stroke::NONE, + )); + } + } + + // Draw stroke + if let Some(stroke_color) = path.stroke { + let stroke = Stroke::new( + path.stroke_width as f32 * self.pan_zoom.zoom, + color_to_egui(stroke_color), + ); + painter.add(egui::Shape::line(egui_points, stroke)); + } else if path.fill.is_none() { + // If no fill and no stroke, draw a default stroke + let stroke = Stroke::new(1.0, egui::Color32::BLACK); + painter.add(egui::Shape::line(egui_points, stroke)); + } + } + } + + /// Convert a world point to screen coordinates. + fn world_to_screen(&self, point: Point, center: Vec2) -> Pos2 { + let world_pt = Pos2::new(point.x as f32, point.y as f32); + self.pan_zoom.world_to_screen(world_pt, center) + } + + /// Update handles for the selected node. + pub fn update_handles_for_node(&mut self, node_name: Option<&str>, state: &AppState) { + use crate::handles::{ellipse_handles, rect_four_point_handle, Handle}; + + match node_name { + Some(name) => { + if let Some(node) = state.library.root.child(name) { + let mut handle_set = HandleSet::new(name); + let mut use_four_point = false; + + if let Some(ref proto) = node.prototype { + match proto.as_str() { + "corevector.ellipse" => { + // Read from "position" Point port (per corevector.ndbx) + let position = node + .input("position") + .and_then(|p| p.value.as_point().cloned()) + .unwrap_or(Point::ZERO); + let width = node + .input("width") + .and_then(|p| p.value.as_float()) + .unwrap_or(100.0); + let height = node + .input("height") + .and_then(|p| p.value.as_float()) + .unwrap_or(100.0); + + for h in ellipse_handles(position.x, position.y, width, height) { + handle_set.add(h); + } + } + "corevector.rect" => { + // Read from "position" Point port (per corevector.ndbx) + let position = node + .input("position") + .and_then(|p| p.value.as_point().cloned()) + .unwrap_or(Point::ZERO); + let width = node + .input("width") + .and_then(|p| p.value.as_float()) + .unwrap_or(100.0); + let height = node + .input("height") + .and_then(|p| p.value.as_float()) + .unwrap_or(100.0); + + // Use FourPointHandle for rect nodes (only update if not dragging) + if self.four_point_handle.as_ref().map_or(true, |h| !h.is_dragging()) { + self.four_point_handle = Some(rect_four_point_handle(name, position.x, position.y, width, height)); + } + use_four_point = true; + } + "corevector.line" => { + let p1 = node + .input("point1") + .and_then(|p| p.value.as_point().cloned()) + .unwrap_or(Point::ZERO); + let p2 = node + .input("point2") + .and_then(|p| p.value.as_point().cloned()) + .unwrap_or(Point::new(100.0, 100.0)); + + handle_set.add( + Handle::point("point1", p1) + .with_color(Color32::from_rgb(255, 100, 100)), + ); + handle_set.add( + Handle::point("point2", p2) + .with_color(Color32::from_rgb(100, 255, 100)), + ); + } + "corevector.polygon" | "corevector.star" => { + // Read from "position" Point port (per corevector.ndbx) + let position = node + .input("position") + .and_then(|p| p.value.as_point().cloned()) + .unwrap_or(Point::ZERO); + + handle_set.add(Handle::point("position", position)); + } + "corevector.freehand" => { + // Read current path string from the node + let path = node + .input("path") + .and_then(|p| p.value.as_string()) + .unwrap_or(""); + + // Initialize freehand state if not already active + if self.freehand_state.is_none() { + self.freehand_state = Some(FreehandState::new("path", path)); + } else if let Some(ref mut fs) = self.freehand_state { + // Sync with node's current value when not drawing + if !fs.is_drawing { + fs.path_string = path.to_string(); + } + } + } + _ => { + self.freehand_state = None; + } + } + } + + // FourPointHandle and regular handles are mutually exclusive + if use_four_point { + self.handles = None; + } else { + self.four_point_handle = None; + if !handle_set.handles().is_empty() { + self.handles = Some(handle_set); + } else { + self.handles = None; + } + } + } else { + self.handles = None; + self.four_point_handle = None; + self.freehand_state = None; + } + } + None => { + self.handles = None; + self.four_point_handle = None; + self.freehand_state = None; + } + } + } +} + +/// Convert a NodeBox color to an egui color. +fn color_to_egui(color: Color) -> egui::Color32 { + egui::Color32::from_rgba_unmultiplied( + (color.r * 255.0) as u8, + (color.g * 255.0) as u8, + (color.b * 255.0) as u8, + (color.a * 255.0) as u8, + ) +} + +/// Evaluate a cubic bezier curve at parameter t. +fn cubic_bezier(p0: Pos2, p1: Pos2, p2: Pos2, p3: Pos2, t: f32) -> Pos2 { + let t2 = t * t; + let t3 = t2 * t; + let mt = 1.0 - t; + let mt2 = mt * mt; + let mt3 = mt2 * mt; + + Pos2::new( + mt3 * p0.x + 3.0 * mt2 * t * p1.x + 3.0 * mt * t2 * p2.x + t3 * p3.x, + mt3 * p0.y + 3.0 * mt2 * t * p1.y + 3.0 * mt * t2 * p2.y + t3 * p3.y, + ) +} + +/// Evaluate a quadratic bezier curve at parameter t. +fn quadratic_bezier(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 { + let t2 = t * t; + let mt = 1.0 - t; + let mt2 = mt * mt; + + Pos2::new( + mt2 * p0.x + 2.0 * mt * t * p1.x + t2 * p2.x, + mt2 * p0.y + 2.0 * mt * t * p1.y + t2 * p2.y, + ) +} diff --git a/crates/nodebox-desktop/tests/cancellation_tests.rs b/crates/nodebox-desktop/tests/cancellation_tests.rs new file mode 100644 index 00000000..d8e1f3db --- /dev/null +++ b/crates/nodebox-desktop/tests/cancellation_tests.rs @@ -0,0 +1,256 @@ +//! Integration tests for render cancellation. + +use std::collections::HashMap; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +use nodebox_core::geometry::Point; +use nodebox_core::node::{Node, NodeLibrary, Port}; +use nodebox_desktop::eval::{EvalOutcome, NodeOutput, evaluate_network_cancellable}; +use nodebox_desktop::render_worker::CancellationToken; +use nodebox_core::platform::{Platform, ProjectContext, TestPlatform}; + +/// Create a test platform and project context for evaluation tests. +fn test_platform_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) +} + +/// Helper to create a library with a large grid that generates many iterations. +fn create_large_grid_library(grid_size: i64) -> NodeLibrary { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("grid1") + .with_prototype("corevector.grid") + .with_input(Port::int("columns", grid_size)) + .with_input(Port::int("rows", grid_size)) + .with_input(Port::float("width", 1000.0)) + .with_input(Port::float("height", 1000.0)) + .with_input(Port::point("position", Point::ZERO)), + ) + .with_child( + Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::ZERO)) + .with_input(Port::float("width", 10.0)) + .with_input(Port::float("height", 10.0)) + .with_input(Port::point("roundness", Point::ZERO)), + ) + .with_connection(nodebox_core::node::Connection::new("grid1", "rect1", "position")) + .with_rendered_child("rect1"); + library +} + +#[test] +fn test_cancellation_token_basic() { + let token = CancellationToken::new(); + assert!(!token.is_cancelled()); + + token.cancel(); + assert!(token.is_cancelled()); +} + +#[test] +fn test_cancellation_token_clone() { + let token = CancellationToken::new(); + let token2 = token.clone(); + + assert!(!token.is_cancelled()); + assert!(!token2.is_cancelled()); + + token.cancel(); + + // Both should see cancellation (shared state) + assert!(token.is_cancelled()); + assert!(token2.is_cancelled()); +} + +#[test] +fn test_evaluation_completes_without_cancellation() { + let library = create_large_grid_library(5); // 5x5 = 25 iterations (small) + let token = CancellationToken::new(); + let mut cache: HashMap = HashMap::new(); + + let (port, ctx) = test_platform_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); + + match outcome { + EvalOutcome::Completed { geometry, errors, .. } => { + assert!(errors.is_empty(), "Should have no errors"); + assert_eq!(geometry.len(), 25, "Should have 25 rectangles (5x5 grid)"); + } + EvalOutcome::Cancelled => { + panic!("Should not be cancelled"); + } + } +} + +#[test] +fn test_evaluation_cancelled_immediately() { + let library = create_large_grid_library(100); // 100x100 = 10000 iterations (large) + let token = CancellationToken::new(); + let mut cache: HashMap = HashMap::new(); + + // Cancel immediately + token.cancel(); + + let (port, ctx) = test_platform_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); + + match outcome { + EvalOutcome::Completed { .. } => { + panic!("Should have been cancelled, not completed"); + } + EvalOutcome::Cancelled => { + // Expected + } + } +} + +#[test] +fn test_cache_preserved_after_cancellation() { + let library = create_large_grid_library(50); // 50x50 = 2500 iterations + let token = CancellationToken::new(); + let mut cache: HashMap = HashMap::new(); + + // Cancel after a short delay + let token_clone = token.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(1)); + token_clone.cancel(); + }); + + let (port, ctx) = test_platform_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); + + // The outcome could be either cancelled or completed depending on timing + // But the cache should have some entries + match outcome { + EvalOutcome::Cancelled => { + // Cache may have partial results - the grid node should be cached + // at minimum since it's evaluated before the rects + } + EvalOutcome::Completed { .. } => { + // If it completed quickly, that's also fine + } + } +} + +#[test] +fn test_cache_reused_after_cancellation() { + let library = create_large_grid_library(10); // 10x10 = 100 iterations + let mut cache: HashMap = HashMap::new(); + + // First render - complete fully + let token1 = CancellationToken::new(); + let (port, ctx) = test_platform_and_context(); + let outcome1 = evaluate_network_cancellable(&library, &token1, &mut cache, &port, &ctx); + + match outcome1 { + EvalOutcome::Completed { geometry, errors, .. } => { + assert!(errors.is_empty()); + assert_eq!(geometry.len(), 100); + } + _ => panic!("First render should complete"), + } + + // Cache should have entries + assert!(!cache.is_empty(), "Cache should have entries after first render"); + let cache_size_after_first = cache.len(); + + // Second render - should reuse cache + let token2 = CancellationToken::new(); + let outcome2 = evaluate_network_cancellable(&library, &token2, &mut cache, &port, &ctx); + + match outcome2 { + EvalOutcome::Completed { geometry, errors, .. } => { + assert!(errors.is_empty()); + assert_eq!(geometry.len(), 100); + } + _ => panic!("Second render should complete"), + } + + // Cache should still have entries (and possibly the same size) + assert_eq!(cache.len(), cache_size_after_first, "Cache size should remain consistent"); +} + +#[test] +fn test_cancellation_response_time() { + // Create a moderately large workload + let library = create_large_grid_library(100); // 100x100 = 10000 iterations + let token = CancellationToken::new(); + + // Start evaluation in a thread + let token_for_thread = token.clone(); + let library_clone = library.clone(); + let handle = thread::spawn(move || { + let mut thread_cache: HashMap = HashMap::new(); + let port: Arc = Arc::new(TestPlatform::new()); + let ctx = ProjectContext::new_unsaved(); + evaluate_network_cancellable(&library_clone, &token_for_thread, &mut thread_cache, &port, &ctx) + }); + + // Wait a bit for evaluation to start, then cancel + thread::sleep(Duration::from_millis(10)); + let cancel_time = Instant::now(); + token.cancel(); + + // Wait for the thread to finish + let _outcome = handle.join().unwrap(); + + // Check response time - should be well under 500ms + let response_time = cancel_time.elapsed(); + assert!( + response_time < Duration::from_millis(500), + "Cancellation response time should be < 500ms, was {:?}", + response_time + ); +} + +#[test] +fn test_multiple_rapid_cancellations() { + // Simulate rapid cancel/restart cycles + for i in 0..10 { + let library = create_large_grid_library(20); // 20x20 = 400 iterations + let token = CancellationToken::new(); + let mut cache: HashMap = HashMap::new(); + + // Alternate between immediate cancel and letting it run + if i % 2 == 0 { + token.cancel(); + } + + let (port, ctx) = test_platform_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); + + // Should not panic or hang regardless of timing + match outcome { + EvalOutcome::Completed { .. } | EvalOutcome::Cancelled => { + // Both are acceptable outcomes + } + } + } +} + +#[test] +fn test_empty_network_not_affected_by_cancellation() { + let library = NodeLibrary::new("empty"); + let token = CancellationToken::new(); + let mut cache: HashMap = HashMap::new(); + + token.cancel(); // Pre-cancel + + let (port, ctx) = test_platform_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); + + // Empty network should complete (nothing to cancel) + match outcome { + EvalOutcome::Completed { geometry, errors, .. } => { + assert!(geometry.is_empty()); + assert!(errors.is_empty()); + } + EvalOutcome::Cancelled => { + // Also acceptable - cancelled before even starting + } + } +} diff --git a/crates/nodebox-gui/tests/common/mod.rs b/crates/nodebox-desktop/tests/common/mod.rs similarity index 99% rename from crates/nodebox-gui/tests/common/mod.rs rename to crates/nodebox-desktop/tests/common/mod.rs index c5fa3dcc..3a3e2c98 100644 --- a/crates/nodebox-gui/tests/common/mod.rs +++ b/crates/nodebox-desktop/tests/common/mod.rs @@ -1,7 +1,7 @@ //! Common test utilities for nodebox-gui tests. #![allow(dead_code)] -use nodebox_gui::{Color, Connection, Node, NodeLibrary, Point, Port}; +use nodebox_desktop::{Color, Connection, Node, NodeLibrary, Point, Port}; /// Create a node library with a single ellipse node. pub fn library_with_ellipse() -> NodeLibrary { diff --git a/crates/nodebox-desktop/tests/file_tests.rs b/crates/nodebox-desktop/tests/file_tests.rs new file mode 100644 index 00000000..9724a7ce --- /dev/null +++ b/crates/nodebox-desktop/tests/file_tests.rs @@ -0,0 +1,461 @@ +//! Tests for loading and evaluating .ndbx files. +//! +//! Note: Old .ndbx files (version < 21) from the Java implementation are loaded +//! best-effort with a warning. These tests verify that behavior and test evaluation +//! with programmatically created libraries. + +use std::path::PathBuf; +use std::sync::Arc; + +use nodebox_core::geometry::{Color, Point}; +use nodebox_core::node::{Connection, Node, NodeLibrary, Port}; +use nodebox_desktop::eval::evaluate_network; +use nodebox_desktop::{populate_default_ports, AppState}; +use nodebox_core::platform::{Platform, ProjectContext, TestPlatform}; + +/// Create a test platform and project context for evaluation tests. +fn test_platform_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) +} + +/// Get the path to the examples directory. +fn examples_dir() -> PathBuf { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() + .unwrap() + .parent() + .unwrap() + .join("examples") +} + +/// Get the path to the libraries directory. +fn libraries_dir() -> PathBuf { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() + .unwrap() + .parent() + .unwrap() + .join("libraries") +} + +// ============================================================================ +// Version compatibility tests +// ============================================================================ + +#[test] +fn test_old_version_files_load_with_warning() { + // The Primitives example has formatVersion="17" which is below our minimum (21) + // but should load best-effort with a warning + let path = examples_dir().join("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); + if !path.exists() { + println!("Skipping test - example file not found"); + return; + } + + let result = nodebox_core::ndbx::parse_file_with_warnings(&path); + assert!(result.is_ok(), "Old version files should load best-effort"); + + let (library, warnings) = result.unwrap(); + assert!(!warnings.is_empty(), "Should have a warning about old format version"); + assert!(warnings[0].contains("17"), "Warning should mention the file's version"); + assert_eq!(library.format_version, 22, "Should be upgraded to current version"); +} + +#[test] +fn test_library_files_can_be_loaded() { + // Library files (corevector, etc.) have no formatVersion attribute, + // which defaults to 21 and is supported + let path = libraries_dir().join("corevector/corevector.ndbx"); + if !path.exists() { + println!("Skipping test - library file not found"); + return; + } + + let library = nodebox_core::ndbx::parse_file(&path).expect("Library files should load"); + + // After upgrade, format version should be 22 + assert_eq!(library.format_version, 22); + + // Check that key nodes exist + assert!(library.root.child("rect").is_some(), "Missing rect node"); + assert!( + library.root.child("ellipse").is_some(), + "Missing ellipse node" + ); +} + +// ============================================================================ +// Evaluation tests with programmatically created libraries +// ============================================================================ + +/// Create a test library similar to the Primitives example +fn create_primitives_library() -> NodeLibrary { + let mut library = NodeLibrary::new("test"); + library.set_width(1000.0); + library.set_height(1000.0); + + // Create nodes similar to the Primitives example + let rect1 = Node::new("rect1") + .with_prototype("corevector.rect") + .with_position(1.0, 1.0) + .with_input(Port::point("position", Point::new(-100.0, 0.0))) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)); + + let ellipse1 = Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_position(4.0, 1.0) + .with_input(Port::point("position", Point::new(10.0, 0.0))) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)); + + let polygon1 = Node::new("polygon1") + .with_prototype("corevector.polygon") + .with_position(7.0, 1.0) + .with_input(Port::point("position", Point::new(100.0, 0.0))) + .with_input(Port::float("radius", 60.0)) + .with_input(Port::int("sides", 6)); + + let colorize1 = Node::new("colorize1") + .with_prototype("corevector.colorize") + .with_position(1.0, 3.0) + .with_input(Port::color("fill", Color::rgba(0.82, 0.42, 0.15, 1.0))); + + let colorize2 = Node::new("colorize2") + .with_prototype("corevector.colorize") + .with_position(4.0, 3.0) + .with_input(Port::color("fill", Color::rgba(0.31, 0.62, 0.96, 1.0))); + + let colorize3 = Node::new("colorize3") + .with_prototype("corevector.colorize") + .with_position(7.0, 3.0) + .with_input(Port::color("fill", Color::rgba(0.0, 0.10, 0.18, 1.0))); + + let combine1 = Node::new("combine1") + .with_prototype("list.combine") + .with_position(3.0, 5.0); + + library.root = Node::network("root") + .with_child(rect1) + .with_child(ellipse1) + .with_child(polygon1) + .with_child(colorize1) + .with_child(colorize2) + .with_child(colorize3) + .with_child(combine1) + .with_connection(Connection::new("rect1", "colorize1", "shape")) + .with_connection(Connection::new("ellipse1", "colorize2", "shape")) + .with_connection(Connection::new("polygon1", "colorize3", "shape")) + .with_connection(Connection::new("colorize1", "combine1", "list1")) + .with_connection(Connection::new("colorize2", "combine1", "list2")) + .with_connection(Connection::new("colorize3", "combine1", "list3")) + .with_rendered_child("combine1"); + + // Populate default ports so connections work properly + populate_default_ports(&mut library.root); + + library +} + +#[test] +fn test_evaluate_primitives() { + let library = create_primitives_library(); + + // Create a library with just the rect node rendered + let mut test_library = library.clone(); + test_library.root.rendered_child = Some("rect1".to_string()); + + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); + assert_eq!(paths.len(), 1, "rect1 should produce one path"); + + // Test ellipse + test_library.root.rendered_child = Some("ellipse1".to_string()); + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); + assert_eq!(paths.len(), 1, "ellipse1 should produce one path"); + + // Test polygon + test_library.root.rendered_child = Some("polygon1".to_string()); + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); + assert_eq!(paths.len(), 1, "polygon1 should produce one path"); +} + +#[test] +fn test_evaluate_primitives_full() { + let library = create_primitives_library(); + + // The rendered child is "combine1" which uses list.combine + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); + + // Should have 3 shapes: rect, ellipse, polygon (each colorized) + assert_eq!(paths.len(), 3, "combine1 should produce 3 colorized paths"); + + // All paths should have fills (they go through colorize nodes) + for path in &paths { + assert!(path.fill.is_some(), "Each path should have a fill color"); + } +} + +#[test] +fn test_evaluate_colorized_primitives() { + let library = create_primitives_library(); + + let mut test_library = library.clone(); + + // Test colorized rect (colorize1 <- rect1) + test_library.root.rendered_child = Some("colorize1".to_string()); + let (port, ctx) = test_platform_and_context(); + let (paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); + + assert_eq!(paths.len(), 1, "colorize1 should produce one path"); + assert!(paths[0].fill.is_some(), "colorized path should have fill"); +} + +// ============================================================================ +// Position port tests - verify shapes respect the "position" Point port +// ============================================================================ + +#[test] +fn test_primitives_shapes_at_different_positions() { + // This test verifies that shapes are at DIFFERENT positions, not all at the origin. + let library = create_primitives_library(); + + // Evaluate rect1 alone + let mut test_library = library.clone(); + test_library.root.rendered_child = Some("rect1".to_string()); + let (port, ctx) = test_platform_and_context(); + let (rect_paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); + assert_eq!(rect_paths.len(), 1, "rect1 should produce one path"); + let rect_bounds = rect_paths[0].bounds().unwrap(); + let rect_center_x = rect_bounds.x + rect_bounds.width / 2.0; + + // Evaluate ellipse1 alone + test_library.root.rendered_child = Some("ellipse1".to_string()); + let (ellipse_paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); + assert_eq!(ellipse_paths.len(), 1, "ellipse1 should produce one path"); + let ellipse_bounds = ellipse_paths[0].bounds().unwrap(); + let ellipse_center_x = ellipse_bounds.x + ellipse_bounds.width / 2.0; + + // Evaluate polygon1 alone + test_library.root.rendered_child = Some("polygon1".to_string()); + let (polygon_paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); + assert_eq!(polygon_paths.len(), 1, "polygon1 should produce one path"); + let polygon_bounds = polygon_paths[0].bounds().unwrap(); + let polygon_center_x = polygon_bounds.x + polygon_bounds.width / 2.0; + + // Verify they are at DIFFERENT x positions as defined + // rect1 should be at x=-100, ellipse1 at x=10, polygon1 at x=100 + assert!( + (rect_center_x - (-100.0)).abs() < 10.0, + "rect1 center X should be near -100, got {}", + rect_center_x + ); + assert!( + (ellipse_center_x - 10.0).abs() < 10.0, + "ellipse1 center X should be near 10, got {}", + ellipse_center_x + ); + assert!( + (polygon_center_x - 100.0).abs() < 10.0, + "polygon1 center X should be near 100, got {}", + polygon_center_x + ); + + // They should NOT all be at the same position (the bug we're catching) + assert!( + (rect_center_x - ellipse_center_x).abs() > 50.0, + "rect1 and ellipse1 should be at different positions! rect={}, ellipse={}", + rect_center_x, + ellipse_center_x + ); + assert!( + (ellipse_center_x - polygon_center_x).abs() > 50.0, + "ellipse1 and polygon1 should be at different positions! ellipse={}, polygon={}", + ellipse_center_x, + polygon_center_x + ); +} + +#[test] +fn test_position_port_is_point_type() { + // Verify that created nodes have "position" port with Point type + let library = create_primitives_library(); + + let rect = library.root.child("rect1").expect("rect1 should exist"); + let position_port = rect.input("position"); + assert!( + position_port.is_some(), + "rect1 should have a 'position' port" + ); + if let Some(port) = position_port { + match &port.value { + nodebox_core::Value::Point(p) => { + assert!( + (p.x - (-100.0)).abs() < 0.1, + "rect1 position.x should be -100, got {}", + p.x + ); + } + other => panic!("rect1 position should be Point type, got {:?}", other), + } + } + + let ellipse = library.root.child("ellipse1").expect("ellipse1 should exist"); + let position_port = ellipse.input("position"); + assert!( + position_port.is_some(), + "ellipse1 should have a 'position' port" + ); + + let polygon = library.root.child("polygon1").expect("polygon1 should exist"); + let position_port = polygon.input("position"); + assert!( + position_port.is_some(), + "polygon1 should have a 'position' port" + ); +} + +// ============================================================================ +// AppState tests +// ============================================================================ + +#[test] +fn test_app_state_new() { + let state = AppState::new(); + + // Initially has demo content + assert!(!state.library.root.children.is_empty()); + assert!(!state.dirty); + assert!(state.current_file.is_none()); +} + +#[test] +fn test_app_state_load_file_old_version_warns() { + let mut state = AppState::new(); + + // Old version files should load best-effort with notifications + let path = examples_dir().join("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); + if !path.exists() { + println!("Skipping test - example file not found"); + return; + } + + let result = state.load_file(&path); + assert!( + result.is_ok(), + "load_file should succeed for old version files (best-effort)" + ); + assert!( + !state.notifications.is_empty(), + "Should have notification warning about old format" + ); +} + +#[test] +fn test_app_state_load_file_nonexistent() { + let mut state = AppState::new(); + + let path = examples_dir().join("nonexistent.ndbx"); + let result = state.load_file(&path); + + assert!( + result.is_err(), + "load_file should fail for nonexistent file" + ); +} + +// ============================================================================ +// Round-trip serialization test +// ============================================================================ + +#[test] +fn test_save_and_reload() { + // Create a library + let original = create_primitives_library(); + + // Serialize to string + let xml = nodebox_core::ndbx::serialize(&original); + + // Parse back + let reloaded = nodebox_core::ndbx::parse(&xml).expect("Should be able to parse serialized content"); + + // Verify key properties + assert_eq!(reloaded.format_version, 22); + assert_eq!(reloaded.root.name, "root"); + assert_eq!( + reloaded.root.rendered_child, + Some("combine1".to_string()) + ); + assert_eq!(reloaded.root.children.len(), 7); + assert_eq!(reloaded.root.connections.len(), 6); + + // Verify a specific node + let rect = reloaded.root.child("rect1").expect("Missing rect1"); + assert_eq!(rect.prototype, Some("corevector.rect".to_string())); +} + +// ============================================================================ +// Bulk loading test for library files (which have no version and default to 21) +// ============================================================================ + +#[test] +fn test_load_all_library_files() { + let libs = libraries_dir(); + if !libs.exists() { + println!("Libraries directory not found, skipping test"); + return; + } + + let mut loaded = 0; + let mut failed = Vec::new(); + + // Walk all library directories + if let Ok(entries) = std::fs::read_dir(&libs) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // Look for .ndbx file in the directory + if let Ok(files) = std::fs::read_dir(&path) { + for file in files.flatten() { + if file + .path() + .extension() + .map_or(false, |e| e == "ndbx") + { + match nodebox_core::ndbx::parse_file(file.path()) { + Ok(library) => { + // Basic sanity check + assert!(!library.root.name.is_empty()); + assert_eq!( + library.format_version, 22, + "Library should be upgraded to v22" + ); + loaded += 1; + } + Err(e) => { + failed.push((file.path(), e.to_string())); + } + } + } + } + } + } + } + } + + println!("Loaded {} library files", loaded); + + if !failed.is_empty() { + println!("Failed to load {} files:", failed.len()); + for (path, err) in &failed { + println!(" {}: {}", path.display(), err); + } + } + + assert!(loaded > 0, "Should have loaded at least one library file"); +} diff --git a/crates/nodebox-gui/tests/handle_tests.rs b/crates/nodebox-desktop/tests/handle_tests.rs similarity index 88% rename from crates/nodebox-gui/tests/handle_tests.rs rename to crates/nodebox-desktop/tests/handle_tests.rs index d99fbeef..c12a865b 100644 --- a/crates/nodebox-gui/tests/handle_tests.rs +++ b/crates/nodebox-desktop/tests/handle_tests.rs @@ -3,10 +3,10 @@ //! These tests verify that handles correctly read position values from nodes //! using the correct port names (matching corevector.ndbx library). -use nodebox_gui::{Node, Point, Port}; +use nodebox_desktop::{Node, Point, Port}; // Import handle functions directly from the module -use nodebox_gui::handles::{ellipse_handles, rect_handles, rect_four_point_handle}; +use nodebox_desktop::handles::{ellipse_handles, rect_handles, rect_four_point_handle}; /// Helper to extract position from a node that uses "position" Point port. /// This mimics what the handle creation code SHOULD do (correct behavior). @@ -27,7 +27,7 @@ fn get_position_from_node_old_broken(node: &Node) -> Point { /// Simulates the handle creation code in canvas.rs/viewer_pane.rs. /// This function mimics the CURRENT (broken) behavior. #[allow(dead_code)] -fn create_ellipse_handles_current_behavior(node: &Node) -> Vec { +fn create_ellipse_handles_current_behavior(node: &Node) -> Vec { // This is what canvas.rs lines 152-157 currently do (BROKEN) let x = node.input("x").and_then(|p| p.value.as_float()).unwrap_or(0.0); let y = node.input("y").and_then(|p| p.value.as_float()).unwrap_or(0.0); @@ -37,7 +37,7 @@ fn create_ellipse_handles_current_behavior(node: &Node) -> Vec Vec { +fn create_ellipse_handles_correct_behavior(node: &Node) -> Vec { let position = node.input("position") .and_then(|p| p.value.as_point().cloned()) .unwrap_or(Point::ZERO); @@ -248,17 +248,36 @@ fn test_star_handle_reads_position_port() { // ============================================================================ #[test] -fn test_loaded_primitives_handles_read_correct_positions() { - use std::path::PathBuf; +fn test_primitives_handles_read_correct_positions() { + use nodebox_core::node::NodeLibrary; - // Get examples directory - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let examples_dir = manifest_dir.parent().unwrap().parent().unwrap().join("examples"); - let path = examples_dir.join("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); + // Create a library similar to the Primitives example + let mut library = NodeLibrary::new("test"); - // Load the file - let mut library = nodebox_ndbx::parse_file(&path).expect("Failed to load primitives example"); - nodebox_gui::populate_default_ports(&mut library.root); + let rect1 = Node::new("rect1") + .with_prototype("corevector.rect") + .with_input(Port::point("position", Point::new(-100.0, 0.0))) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)); + + let ellipse1 = Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::point("position", Point::new(10.0, 0.0))) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)); + + let polygon1 = Node::new("polygon1") + .with_prototype("corevector.polygon") + .with_input(Port::point("position", Point::new(100.0, 0.0))) + .with_input(Port::float("radius", 60.0)) + .with_input(Port::int("sides", 6)); + + library.root = Node::network("root") + .with_child(rect1) + .with_child(ellipse1) + .with_child(polygon1); + + nodebox_desktop::populate_default_ports(&mut library.root); // Get nodes let rect = library.root.child("rect1").expect("rect1 should exist"); @@ -270,10 +289,10 @@ fn test_loaded_primitives_handles_read_correct_positions() { let ellipse_pos = get_position_from_node(ellipse); let polygon_pos = get_position_from_node(polygon); - // These are the values from the file: - // rect1: position="-100.00,0.00" - // ellipse1: position="10.00,0.00" - // polygon1: position="100.00,0.00" + // These are the values we set: + // rect1: position="-100.0,0.0" + // ellipse1: position="10.0,0.0" + // polygon1: position="100.0,0.0" assert!( (rect_pos.x - (-100.0)).abs() < 0.1, diff --git a/crates/nodebox-desktop/tests/history_tests.rs b/crates/nodebox-desktop/tests/history_tests.rs new file mode 100644 index 00000000..b4d2f16d --- /dev/null +++ b/crates/nodebox-desktop/tests/history_tests.rs @@ -0,0 +1,457 @@ +//! Tests for undo/redo history functionality. + +mod common; + +use std::sync::Arc; +use nodebox_desktop::{History, SelectionSnapshot, Node, NodeLibrary, Port}; + +/// Create a simple test library with an ellipse. +fn create_test_library(x: f64) -> Arc { + let mut library = NodeLibrary::new("test"); + library.root = Node::network("root") + .with_child( + Node::new("ellipse1") + .with_prototype("corevector.ellipse") + .with_input(Port::float("x", x)) + .with_input(Port::float("y", 0.0)) + .with_input(Port::float("width", 100.0)) + .with_input(Port::float("height", 100.0)), + ) + .with_rendered_child("ellipse1"); + Arc::new(library) +} + +fn default_sel() -> SelectionSnapshot { + SelectionSnapshot::default() +} + +#[test] +fn test_history_new_is_empty() { + let history = History::new(); + assert!(!history.can_undo()); + assert!(!history.can_redo()); + assert_eq!(history.undo_count(), 0); + assert_eq!(history.redo_count(), 0); +} + +#[test] +fn test_history_save_enables_undo() { + let mut history = History::new(); + let library = create_test_library(0.0); + + history.save_state(&library, &default_sel()); + + assert!(history.can_undo()); + assert!(!history.can_redo()); + assert_eq!(history.undo_count(), 1); +} + +#[test] +fn test_history_undo_restores_previous_state() { + let mut history = History::new(); + + // Save initial state + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + + // Current state has different x value + let library_v2 = create_test_library(100.0); + + // Undo should restore v1 + let (restored, _) = history.undo(&library_v2, &default_sel()).unwrap(); + + // Check that the x value was restored + let node = restored.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 0.0).abs() < 0.001); +} + +#[test] +fn test_history_undo_enables_redo() { + let mut history = History::new(); + + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + + let library_v2 = create_test_library(100.0); + history.undo(&library_v2, &default_sel()); + + assert!(history.can_redo()); + assert_eq!(history.redo_count(), 1); +} + +#[test] +fn test_history_redo_restores_undone_state() { + let mut history = History::new(); + + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + + let library_v2 = create_test_library(100.0); + + // Undo returns v1 + let (after_undo, _) = history.undo(&library_v2, &default_sel()).unwrap(); + + // Redo should return v2 + let (after_redo, _) = history.redo(&after_undo, &default_sel()).unwrap(); + + let node = after_redo.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 100.0).abs() < 0.001); +} + +#[test] +fn test_history_new_changes_clear_redo_stack() { + let mut history = History::new(); + + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + + let library_v2 = create_test_library(100.0); + + // Undo to enable redo + history.undo(&library_v2, &default_sel()); + assert!(history.can_redo()); + + // Save new state (simulating new change) + let library_v3 = create_test_library(50.0); + history.save_state(&library_v3, &default_sel()); + + // Redo should now be unavailable + assert!(!history.can_redo()); + assert_eq!(history.redo_count(), 0); +} + +#[test] +fn test_history_multiple_undos() { + let mut history = History::new(); + + // Create and save multiple states + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + + let library_v2 = create_test_library(50.0); + history.save_state(&library_v2, &default_sel()); + + let library_v3 = create_test_library(100.0); + + assert_eq!(history.undo_count(), 2); + + // Undo twice + let (after_first_undo, _) = history.undo(&library_v3, &default_sel()).unwrap(); + let node = after_first_undo.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 50.0).abs() < 0.001); + + let (after_second_undo, _) = history.undo(&after_first_undo, &default_sel()).unwrap(); + let node = after_second_undo.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 0.0).abs() < 0.001); + + // No more undos available + assert!(!history.can_undo()); +} + +#[test] +fn test_history_multiple_redos() { + let mut history = History::new(); + + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + + let library_v2 = create_test_library(50.0); + history.save_state(&library_v2, &default_sel()); + + let library_v3 = create_test_library(100.0); + + // Undo twice + let (after_first_undo, _) = history.undo(&library_v3, &default_sel()).unwrap(); + let (after_second_undo, _) = history.undo(&after_first_undo, &default_sel()).unwrap(); + + assert_eq!(history.redo_count(), 2); + + // Redo twice + let (after_first_redo, _) = history.redo(&after_second_undo, &default_sel()).unwrap(); + let node = after_first_redo.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 50.0).abs() < 0.001); + + let (after_second_redo, _) = history.redo(&after_first_redo, &default_sel()).unwrap(); + let node = after_second_redo.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 100.0).abs() < 0.001); + + // No more redos available + assert!(!history.can_redo()); +} + +#[test] +fn test_history_clear() { + let mut history = History::new(); + + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + history.save_state(&library_v1, &default_sel()); + history.save_state(&library_v1, &default_sel()); + + assert_eq!(history.undo_count(), 3); + + history.clear(); + + assert!(!history.can_undo()); + assert!(!history.can_redo()); + assert_eq!(history.undo_count(), 0); + assert_eq!(history.redo_count(), 0); +} + +#[test] +fn test_history_mark_saved_and_unsaved_changes() { + let mut history = History::new(); + + let library_v1 = create_test_library(0.0); + history.mark_saved(&library_v1); + + // Same library should not have unsaved changes + assert!(!history.has_unsaved_changes(&library_v1)); + + // Different library should have unsaved changes + let library_v2 = create_test_library(100.0); + assert!(history.has_unsaved_changes(&library_v2)); +} + +#[test] +fn test_history_undo_on_empty_returns_none() { + let mut history = History::new(); + let library = create_test_library(0.0); + + let result = history.undo(&library, &default_sel()); + assert!(result.is_none()); +} + +#[test] +fn test_history_redo_on_empty_returns_none() { + let mut history = History::new(); + let library = create_test_library(0.0); + + let result = history.redo(&library, &default_sel()); + assert!(result.is_none()); +} + +// --- Undo group tests --- + +#[test] +fn test_save_state_suppressed_during_group() { + let mut history = History::new(); + let library_v1 = create_test_library(0.0); + + // Begin a group + history.begin_undo_group(&library_v1, &default_sel()); + + // Multiple save_state calls should be suppressed + for i in 1..=5 { + let lib = create_test_library(i as f64 * 10.0); + history.save_state(&lib, &default_sel()); + } + + // End the group with the final state + let library_final = create_test_library(50.0); + history.end_undo_group(&library_final); + + // Only one undo entry (the group itself) + assert_eq!(history.undo_count(), 1); +} + +#[test] +fn test_group_undo_restores_pre_group_state() { + let mut history = History::new(); + let library_v1 = create_test_library(0.0); + + // Begin group with state A (x=0) + history.begin_undo_group(&library_v1, &default_sel()); + + // Simulate intermediate changes during drag + let library_v2 = create_test_library(25.0); + history.save_state(&library_v2, &default_sel()); + let library_v3 = create_test_library(50.0); + history.save_state(&library_v3, &default_sel()); + + // End group with final state (x=50) + history.end_undo_group(&library_v3); + + assert_eq!(history.undo_count(), 1); + + // Undo should restore the pre-group state (x=0) + let (restored, _) = history.undo(&library_v3, &default_sel()).unwrap(); + let node = restored.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 0.0).abs() < 0.001, "Expected x=0.0 (pre-group), got x={}", x); +} + +#[test] +fn test_end_group_without_begin_is_noop() { + let mut history = History::new(); + let library = create_test_library(0.0); + + // end_undo_group without begin should not crash or add entries + history.end_undo_group(&library); + + assert_eq!(history.undo_count(), 0); + assert!(!history.can_undo()); +} + +#[test] +fn test_no_op_group_creates_no_entry() { + let mut history = History::new(); + let library = create_test_library(0.0); + + // Begin and end group with the same state (no actual changes) + history.begin_undo_group(&library, &default_sel()); + history.end_undo_group(&library); + + // No undo entry should be created since nothing changed + assert_eq!(history.undo_count(), 0); +} + +#[test] +fn test_group_clears_redo_stack() { + let mut history = History::new(); + + // Set up: create some undo history, then undo to populate redo stack + let library_v1 = create_test_library(0.0); + history.save_state(&library_v1, &default_sel()); + let library_v2 = create_test_library(50.0); + history.undo(&library_v2, &default_sel()); + assert!(history.can_redo()); + + // Now perform a group operation + let library_v3 = create_test_library(100.0); + history.begin_undo_group(&library_v3, &default_sel()); + let library_v4 = create_test_library(200.0); + history.end_undo_group(&library_v4); + + // Redo stack should be cleared by the group + assert!(!history.can_redo()); + assert_eq!(history.redo_count(), 0); +} + +#[test] +fn test_nested_begin_group_keeps_first() { + let mut history = History::new(); + let library_a = create_test_library(0.0); + let library_b = create_test_library(50.0); + + // First begin_undo_group captures state A + history.begin_undo_group(&library_a, &default_sel()); + + // Second begin_undo_group should be ignored (first wins) + history.begin_undo_group(&library_b, &default_sel()); + + let library_c = create_test_library(100.0); + history.end_undo_group(&library_c); + + // Undo should restore A (from the first begin), not B + let (restored, _) = history.undo(&library_c, &default_sel()).unwrap(); + let node = restored.root.child("ellipse1").unwrap(); + let x = node.input("x").unwrap().value.as_float().unwrap(); + assert!((x - 0.0).abs() < 0.001, "Expected x=0.0 (first begin), got x={}", x); +} + +#[test] +fn test_group_followed_by_normal_saves() { + let mut history = History::new(); + + // Do a grouped operation + let library_v1 = create_test_library(0.0); + history.begin_undo_group(&library_v1, &default_sel()); + let library_v2 = create_test_library(50.0); + history.end_undo_group(&library_v2); + + // Then do normal saves + let library_v3 = create_test_library(75.0); + history.save_state(&library_v3, &default_sel()); + + // Should have 2 entries: the group + the normal save + assert_eq!(history.undo_count(), 2); +} + +#[test] +fn test_is_in_group() { + let mut history = History::new(); + let library = create_test_library(0.0); + + assert!(!history.is_in_group()); + + history.begin_undo_group(&library, &default_sel()); + assert!(history.is_in_group()); + + history.end_undo_group(&library); + assert!(!history.is_in_group()); +} + +// --- Selection snapshot tests --- + +#[test] +fn test_undo_restores_selection_snapshot() { + let mut history = History::new(); + let library_v1 = create_test_library(0.0); + + // Save state with ellipse1 selected + let sel_v1 = SelectionSnapshot { + selected_nodes: ["ellipse1".to_string()].into_iter().collect(), + selected_node: Some("ellipse1".to_string()), + }; + history.save_state(&library_v1, &sel_v1); + + // Current state: no selection + let library_v2 = create_test_library(100.0); + let sel_v2 = SelectionSnapshot::default(); + + // Undo should restore the selection + let (_, restored_sel) = history.undo(&library_v2, &sel_v2).unwrap(); + assert_eq!(restored_sel.selected_node, Some("ellipse1".to_string())); + assert!(restored_sel.selected_nodes.contains("ellipse1")); +} + +#[test] +fn test_redo_restores_selection_snapshot() { + let mut history = History::new(); + let library_v1 = create_test_library(0.0); + + // Save state with no selection + history.save_state(&library_v1, &default_sel()); + + // Current state: ellipse1 selected + let library_v2 = create_test_library(100.0); + let sel_v2 = SelectionSnapshot { + selected_nodes: ["ellipse1".to_string()].into_iter().collect(), + selected_node: Some("ellipse1".to_string()), + }; + + // Undo (saves sel_v2 for redo) + history.undo(&library_v2, &sel_v2); + + // Redo should restore sel_v2 + let (_, restored_sel) = history.redo(&library_v1, &default_sel()).unwrap(); + assert_eq!(restored_sel.selected_node, Some("ellipse1".to_string())); +} + +#[test] +fn test_undo_group_restores_pre_group_selection() { + let mut history = History::new(); + let library_v1 = create_test_library(0.0); + + // Begin group with ellipse1 selected + let sel = SelectionSnapshot { + selected_nodes: ["ellipse1".to_string()].into_iter().collect(), + selected_node: Some("ellipse1".to_string()), + }; + history.begin_undo_group(&library_v1, &sel); + + // End group with changed library + let library_v2 = create_test_library(100.0); + history.end_undo_group(&library_v2); + + // Undo should restore the pre-group selection + let (_, restored_sel) = history.undo(&library_v2, &default_sel()).unwrap(); + assert_eq!(restored_sel.selected_node, Some("ellipse1".to_string())); +} diff --git a/crates/nodebox-gui/tests/integration_tests.rs b/crates/nodebox-desktop/tests/integration_tests.rs similarity index 99% rename from crates/nodebox-gui/tests/integration_tests.rs rename to crates/nodebox-desktop/tests/integration_tests.rs index 2889fd2b..7d98ca4a 100644 --- a/crates/nodebox-gui/tests/integration_tests.rs +++ b/crates/nodebox-desktop/tests/integration_tests.rs @@ -97,7 +97,7 @@ fn create_menu_bar_harness() -> Harness<'static> { Harness::builder() .with_size(egui::vec2(800.0, 40.0)) .build_ui(|ui| { - egui::menu::bar(ui, |ui| { + egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() {} if ui.button("Open...").clicked() {} diff --git a/crates/nodebox-gui/src/address_bar.rs b/crates/nodebox-gui/src/address_bar.rs deleted file mode 100644 index 4c9ef64b..00000000 --- a/crates/nodebox-gui/src/address_bar.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Address bar with breadcrumb navigation. - -#![allow(dead_code)] - -use eframe::egui::{self, Sense}; -use crate::theme; - -/// The address bar showing current network path. -pub struct AddressBar { - /// Path segments (e.g., ["root", "network1"]). - segments: Vec, - /// Status message displayed on the right. - message: String, - /// Hovered segment index (for highlighting). - hovered_segment: Option, -} - -impl Default for AddressBar { - fn default() -> Self { - Self::new() - } -} - -impl AddressBar { - /// Create a new address bar. - pub fn new() -> Self { - Self { - segments: vec!["root".to_string()], - message: String::new(), - hovered_segment: None, - } - } - - /// Set the current path from a path string (e.g., "/root/network1"). - pub fn set_path(&mut self, path: &str) { - self.segments = path - .trim_matches('/') - .split('/') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - if self.segments.is_empty() { - self.segments.push("root".to_string()); - } - } - - /// Set the status message. - pub fn set_message(&mut self, message: impl Into) { - self.message = message.into(); - } - - /// Clear the status message. - pub fn clear_message(&mut self) { - self.message.clear(); - } - - /// Get the current path as a string. - pub fn path(&self) -> String { - format!("/{}", self.segments.join("/")) - } - - /// Show the address bar. Returns the clicked path if a segment was clicked. - pub fn show(&mut self, ui: &mut egui::Ui) -> Option { - let mut clicked_path = None; - self.hovered_segment = None; - - // Clean background - uses panel bg for seamless integration - let rect = ui.available_rect_before_wrap(); - ui.painter().rect_filled(rect, 0.0, theme::PANEL_BG); - - ui.horizontal(|ui| { - ui.add_space(theme::PADDING); - - // Draw path segments with separators - smaller, more subtle - for (i, segment) in self.segments.iter().enumerate() { - // Separator (except before first segment) - if i > 0 { - ui.label( - egui::RichText::new("/") - .color(theme::TEXT_DISABLED) - .size(11.0), - ); - } - - // Segment as clickable text - subtle styling - let is_last = i == self.segments.len() - 1; - let text_color = if is_last { - theme::TEXT_DEFAULT - } else { - theme::TEXT_SUBDUED - }; - - let response = ui.add( - egui::Label::new( - egui::RichText::new(segment) - .color(text_color) - .size(11.0), - ) - .sense(Sense::click()), - ); - - // Subtle hover effect - if response.hovered() { - self.hovered_segment = Some(i); - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - - // Handle click - navigate to this segment's path - if response.clicked() { - let path = format!( - "/{}", - self.segments[..=i].join("/") - ); - clicked_path = Some(path); - } - } - - // Right-aligned status message - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(theme::PADDING); - if !self.message.is_empty() { - ui.label( - egui::RichText::new(&self.message) - .color(theme::TEXT_DISABLED) - .size(10.0), - ); - } - }); - }); - - clicked_path - } -} diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs deleted file mode 100644 index e66c3e17..00000000 --- a/crates/nodebox-gui/src/app.rs +++ /dev/null @@ -1,749 +0,0 @@ -//! Main application state and update loop. - -use eframe::egui::{self, Pos2, Rect, Vec2}; -use nodebox_core::geometry::Point; -use crate::address_bar::AddressBar; -use crate::animation_bar::AnimationBar; -use crate::components; -use crate::history::History; -use crate::icon_cache::IconCache; -use crate::native_menu::{MenuAction, NativeMenuHandle}; -use crate::network_view::{NetworkAction, NetworkView}; -use crate::node_selection_dialog::NodeSelectionDialog; -use crate::panels::ParameterPanel; -use crate::render_worker::{RenderResult, RenderState, RenderWorkerHandle}; -use crate::state::AppState; -use crate::theme; -use crate::viewer_pane::{HandleResult, ViewerPane}; - -/// The main NodeBox application. -pub struct NodeBoxApp { - state: AppState, - address_bar: AddressBar, - viewer_pane: ViewerPane, - network_view: NetworkView, - parameters: ParameterPanel, - animation_bar: AnimationBar, - node_dialog: NodeSelectionDialog, - /// Shared icon cache for the node selection dialog. - icon_cache: IconCache, - history: History, - /// Previous library state for detecting changes. - previous_library_hash: u64, - /// Background render worker. - render_worker: RenderWorkerHandle, - /// State tracking for render requests. - render_state: RenderState, - /// Whether a render is pending (needs to be dispatched). - render_pending: bool, - /// Native menu handle for macOS system menu bar. - native_menu: Option, -} - -impl NodeBoxApp { - /// Create a new NodeBox application instance. - #[allow(dead_code)] - pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { - Self::new_with_file(_cc, None, None) - } - - /// Create a new NodeBox application instance, optionally loading an initial file. - pub fn new_with_file( - cc: &eframe::CreationContext<'_>, - initial_file: Option, - native_menu: Option, - ) -> Self { - // Configure the global theme/style - theme::configure_style(&cc.egui_ctx); - - let mut state = AppState::new(); - - // Load the initial file if provided - if let Some(ref path) = initial_file { - if let Err(e) = state.load_file(path) { - log::error!("Failed to load initial file {:?}: {}", path, e); - } - } - - let hash = Self::hash_library(&state.library); - Self { - state, - address_bar: AddressBar::new(), - viewer_pane: ViewerPane::new(), - network_view: NetworkView::new(), - parameters: ParameterPanel::new(), - animation_bar: AnimationBar::new(), - node_dialog: NodeSelectionDialog::new(), - icon_cache: IconCache::new(), - history: History::new(), - previous_library_hash: hash, - render_worker: RenderWorkerHandle::spawn(), - render_state: RenderState::new(), - render_pending: false, // Initial geometry is already evaluated in AppState::new() - native_menu, - } - } - - /// Create a new NodeBox application instance for testing. - /// - /// This constructor creates an app without spawning a render worker thread, - /// making it suitable for unit tests and integration tests. - #[cfg(test)] - #[allow(dead_code)] - pub fn new_for_testing() -> Self { - let state = AppState::new(); - let hash = Self::hash_library(&state.library); - Self { - state, - address_bar: AddressBar::new(), - viewer_pane: ViewerPane::new(), - network_view: NetworkView::new(), - parameters: ParameterPanel::new(), - animation_bar: AnimationBar::new(), - node_dialog: NodeSelectionDialog::new(), - icon_cache: IconCache::new(), - history: History::new(), - previous_library_hash: hash, - render_worker: RenderWorkerHandle::spawn(), - render_state: RenderState::new(), - render_pending: false, - native_menu: None, - } - } - - /// Create a new NodeBox application instance for testing with an empty library. - /// - /// This is useful for tests that need to set up their own node configuration. - #[cfg(test)] - #[allow(dead_code)] - pub fn new_for_testing_empty() -> Self { - let mut state = AppState::new(); - state.library = nodebox_core::node::NodeLibrary::new("test"); - state.geometry.clear(); - let hash = Self::hash_library(&state.library); - Self { - state, - address_bar: AddressBar::new(), - viewer_pane: ViewerPane::new(), - network_view: NetworkView::new(), - parameters: ParameterPanel::new(), - animation_bar: AnimationBar::new(), - node_dialog: NodeSelectionDialog::new(), - icon_cache: IconCache::new(), - history: History::new(), - previous_library_hash: hash, - render_worker: RenderWorkerHandle::spawn(), - render_state: RenderState::new(), - render_pending: false, - native_menu: None, - } - } - - /// Get a reference to the application state. - #[allow(dead_code)] - pub fn state(&self) -> &AppState { - &self.state - } - - /// Get a mutable reference to the application state. - #[allow(dead_code)] - pub fn state_mut(&mut self) -> &mut AppState { - &mut self.state - } - - /// Get a reference to the history manager. - #[allow(dead_code)] - pub fn history(&self) -> &History { - &self.history - } - - /// Get a mutable reference to the history manager. - #[allow(dead_code)] - pub fn history_mut(&mut self) -> &mut History { - &mut self.history - } - - /// Synchronously evaluate the network for testing. - /// - /// Unlike the normal async flow, this directly evaluates and updates geometry. - #[cfg(test)] - #[allow(dead_code)] - pub fn evaluate_for_testing(&mut self) { - self.state.evaluate(); - } - - /// Simulate a frame update for testing purposes. - /// - /// This checks for changes and updates history, similar to what happens - /// during a normal frame update, but without the async render worker. - #[cfg(test)] - #[allow(dead_code)] - pub fn update_for_testing(&mut self) { - // Check for changes and save to history - let current_hash = Self::hash_library(&self.state.library); - if current_hash != self.previous_library_hash { - self.history.save_state(&self.state.library); - self.previous_library_hash = current_hash; - self.state.dirty = true; - } - // Synchronously evaluate - self.state.evaluate(); - } - - /// Compute a simple hash of the library for change detection. - fn hash_library(library: &nodebox_core::node::NodeLibrary) -> u64 { - use std::hash::{Hash, Hasher}; - use std::collections::hash_map::DefaultHasher; - let mut hasher = DefaultHasher::new(); - - // Hash the number of children and their names/positions - library.root.children.len().hash(&mut hasher); - for child in &library.root.children { - child.name.hash(&mut hasher); - (child.position.x as i64).hash(&mut hasher); - (child.position.y as i64).hash(&mut hasher); - child.inputs.len().hash(&mut hasher); - - // Hash port values - for port in &child.inputs { - port.name.hash(&mut hasher); - // Hash the value - convert to string representation for simplicity - format!("{:?}", port.value).hash(&mut hasher); - } - } - - // Hash connections - library.root.connections.len().hash(&mut hasher); - for conn in &library.root.connections { - conn.output_node.hash(&mut hasher); - conn.input_node.hash(&mut hasher); - conn.input_port.hash(&mut hasher); - } - - // Hash rendered child - library.root.rendered_child.hash(&mut hasher); - - hasher.finish() - } - - /// Poll for render results and dispatch pending renders. - fn poll_render_results(&mut self) { - // Check for completed renders - while let Some(result) = self.render_worker.try_recv_result() { - if let RenderResult::Success { id, geometry } = result { - if self.render_state.is_current(id) { - self.state.geometry = geometry; - self.render_state.complete(); - } - } - } - - // Dispatch pending render if not already rendering - if self.render_pending && !self.render_state.is_rendering { - let id = self.render_state.dispatch_new(); - self.render_worker.request_render(id, self.state.library.clone()); - self.render_pending = false; - } - } - - /// Check for changes and save to history, queue render if needed. - fn check_for_changes(&mut self) { - let current_hash = Self::hash_library(&self.state.library); - if current_hash != self.previous_library_hash { - self.history.save_state(&self.state.library); - self.previous_library_hash = current_hash; - self.state.dirty = true; - self.render_pending = true; // Queue async render - } - } - - /// Handle a menu action from the native menu bar. - fn handle_menu_action(&mut self, action: MenuAction, ctx: &egui::Context) { - match action { - MenuAction::New => self.state.new_document(), - MenuAction::Open => self.open_file(), - MenuAction::Save => self.save_file(), - MenuAction::SaveAs => self.save_file_as(), - MenuAction::ExportPng => self.export_png(), - MenuAction::ExportSvg => self.export_svg(), - MenuAction::Undo => { - if let Some(previous) = self.history.undo(&self.state.library) { - self.state.library = previous; - self.previous_library_hash = Self::hash_library(&self.state.library); - self.render_pending = true; - } - } - MenuAction::Redo => { - if let Some(next) = self.history.redo(&self.state.library) { - self.state.library = next; - self.previous_library_hash = Self::hash_library(&self.state.library); - self.render_pending = true; - } - } - MenuAction::ZoomIn => self.viewer_pane.zoom_in(), - MenuAction::ZoomOut => self.viewer_pane.zoom_out(), - MenuAction::ZoomReset => self.viewer_pane.reset_zoom(), - MenuAction::About => self.state.show_about = true, - // Clipboard actions handled by system - MenuAction::Cut | MenuAction::Copy | MenuAction::Paste | - MenuAction::Delete | MenuAction::SelectAll => {} - } - ctx.request_repaint(); - } - - /// Show the menu bar. - #[cfg(not(target_os = "macos"))] - fn show_menu_bar(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - egui::menu::bar(ui, |ui| { - ui.menu_button("File", |ui| { - if ui.button("New").clicked() { - self.state.new_document(); - ui.close_menu(); - } - if ui.button("Open...").clicked() { - self.open_file(); - ui.close_menu(); - } - if ui.button("Save").clicked() { - self.save_file(); - ui.close_menu(); - } - if ui.button("Save As...").clicked() { - self.save_file_as(); - ui.close_menu(); - } - ui.separator(); - if ui.button("Export SVG...").clicked() { - self.export_svg(); - ui.close_menu(); - } - if ui.button("Export PNG...").clicked() { - self.export_png(); - ui.close_menu(); - } - ui.separator(); - if ui.button("Quit").clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - - ui.menu_button("Edit", |ui| { - let undo_text = if self.history.can_undo() { - format!("Undo ({})", self.history.undo_count()) - } else { - "Undo".to_string() - }; - if ui.add_enabled(self.history.can_undo(), egui::Button::new(undo_text)).clicked() { - if let Some(previous) = self.history.undo(&self.state.library) { - self.state.library = previous; - self.previous_library_hash = Self::hash_library(&self.state.library); - self.render_pending = true; - } - ui.close_menu(); - } - let redo_text = if self.history.can_redo() { - format!("Redo ({})", self.history.redo_count()) - } else { - "Redo".to_string() - }; - if ui.add_enabled(self.history.can_redo(), egui::Button::new(redo_text)).clicked() { - if let Some(next) = self.history.redo(&self.state.library) { - self.state.library = next; - self.previous_library_hash = Self::hash_library(&self.state.library); - self.render_pending = true; - } - ui.close_menu(); - } - ui.separator(); - if ui.button("Delete Selected").clicked() { - ui.close_menu(); - } - }); - - ui.menu_button("View", |ui| { - if ui.button("Zoom In").clicked() { - self.viewer_pane.zoom_in(); - ui.close_menu(); - } - if ui.button("Zoom Out").clicked() { - self.viewer_pane.zoom_out(); - ui.close_menu(); - } - if ui.button("Fit to Window").clicked() { - self.viewer_pane.fit_to_window(); - ui.close_menu(); - } - ui.separator(); - ui.checkbox(&mut self.viewer_pane.show_handles, "Show Handles"); - ui.checkbox(&mut self.viewer_pane.show_points, "Show Points"); - ui.checkbox(&mut self.viewer_pane.show_origin, "Show Origin"); - ui.checkbox(&mut self.viewer_pane.show_canvas_border, "Show Canvas"); - }); - - ui.menu_button("Help", |ui| { - if ui.button("About NodeBox").clicked() { - self.state.show_about = true; - ui.close_menu(); - } - }); - }); - } -} - -impl eframe::App for NodeBoxApp { - #[allow(unused_variables)] - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - // Poll for native menu events (macOS system menu bar) - if let Some(ref native_menu) = self.native_menu { - if let Some(action) = native_menu.poll_event() { - self.handle_menu_action(action, ctx); - } - } - - // Poll for background render results - self.poll_render_results(); - - // Request repaint while rendering is in progress - if self.render_state.is_rendering || self.render_pending { - ctx.request_repaint(); - } - - // 1. Menu bar (top-most) - only show in-window menu on non-macOS platforms - #[cfg(not(target_os = "macos"))] - egui::TopBottomPanel::top("menu_bar") - .frame(egui::Frame::NONE.fill(theme::PANEL_BG)) - .show(ctx, |ui| { - self.show_menu_bar(ui, ctx); - }); - - // 2. Address bar (below menu) - frameless, handles its own styling - egui::TopBottomPanel::top("address_bar") - .exact_height(theme::ADDRESS_BAR_HEIGHT) - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - // Update address bar message with current state - let node_count = self.state.library.root.children.len(); - let msg = format!("{} nodes · {:.0}%", node_count, self.viewer_pane.zoom() * 100.0); - self.address_bar.set_message(msg); - - if let Some(_clicked_path) = self.address_bar.show(ui) { - // Future: navigate to sub-network - } - }); - - // 3. Animation bar (bottom) - frameless, handles its own styling - egui::TopBottomPanel::bottom("animation_bar") - .exact_height(theme::ANIMATION_BAR_HEIGHT) - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - let _event = self.animation_bar.show(ui); - }); - - // Update animation playback - if self.animation_bar.is_playing() { - self.animation_bar.update(); - ctx.request_repaint(); - } - - // 4. Right side panel containing Parameters (top) and Network (bottom) - egui::SidePanel::right("right_panel") - .default_width(450.0) - .min_width(300.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(theme::PANEL_BG)) - .show(ctx, |ui| { - // Remove default spacing to have tighter control - ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); - - let available = ui.available_rect_before_wrap(); - let split_ratio = 0.35; // 35% parameters, 65% network - let split_y = available.height() * split_ratio; - - // Top: Parameters pane - let params_rect = Rect::from_min_size( - available.min, - Vec2::new(available.width(), split_y), - ); - - ui.scope_builder(egui::UiBuilder::new().max_rect(params_rect), |ui| { - ui.set_clip_rect(params_rect); - self.parameters.show(ui, &mut self.state); - }); - - // Bottom: Network pane (headers have their own borders) - let network_rect = Rect::from_min_max( - Pos2::new(available.min.x, available.min.y + split_y), - available.max, - ); - - ui.scope_builder(egui::UiBuilder::new().max_rect(network_rect), |ui| { - ui.set_clip_rect(network_rect); - - // Network header with "+ New Node" button - let (header_rect, x) = components::draw_pane_header_with_title(ui, "Network"); - - // "+ New Node" button after the separator - let (clicked, _) = components::header_text_button( - ui, - header_rect, - x, - "+ New Node", - 70.0, - ); - - if clicked { - self.node_dialog.open(Point::new(0.0, 0.0)); - } - - // Network view - let action = self.network_view.show(ui, &mut self.state.library); - - // Handle network actions - match action { - NetworkAction::OpenNodeDialog(pos) => { - self.node_dialog.open(pos); - } - NetworkAction::None => {} - } - - // Update selected node from network view - let selected = self.network_view.selected_nodes(); - if selected.len() == 1 { - self.state.selected_node = selected.iter().next().cloned(); - } else if selected.is_empty() { - self.state.selected_node = None; - } - }); - }); - - // 5. Central panel: Viewer (left side, takes remaining space) - clean frame - egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(theme::PANEL_BG)) - .show(ctx, |ui| { - // Update handles for selected node - self.viewer_pane.update_handles_for_node( - self.state.selected_node.as_deref(), - &self.state, - ); - - // Show viewer and handle interactions - // Get wgpu render state for GPU-accelerated rendering (when available) - #[cfg(feature = "gpu-rendering")] - let render_state = frame.wgpu_render_state(); - #[cfg(not(feature = "gpu-rendering"))] - let render_state: Option<&crate::viewer_pane::RenderState> = None; - - let result = self.viewer_pane.show(ui, &self.state, render_state); - match result { - HandleResult::PointChange { param, value } => { - self.handle_parameter_change(¶m, value); - } - HandleResult::FourPointChange { x, y, width, height } => { - self.handle_four_point_change(x, y, width, height); - } - HandleResult::None => {} - } - }); - - // 6. Node selection dialog - if self.node_dialog.visible { - if let Some(new_node) = self.node_dialog.show(ctx, &self.state.library, &mut self.icon_cache) { - let node_name = new_node.name.clone(); - self.state.library.root.children.push(new_node); - // Select the new node - self.state.selected_node = Some(node_name); - } - } - - // 7. About dialog - if self.state.show_about { - egui::Window::new("About NodeBox") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.vertical_centered(|ui| { - ui.heading("NodeBox"); - ui.label("Version 4.0 (Rust)"); - ui.add_space(10.0); - ui.label("A node-based generative design tool"); - ui.add_space(10.0); - ui.hyperlink_to("Visit website", "https://www.nodebox.net"); - ui.add_space(10.0); - if ui.button("Close").clicked() { - self.state.show_about = false; - } - }); - }); - } - - // Handle keyboard shortcuts - let (do_undo, do_redo) = ctx.input(|i| { - let undo = i.modifiers.command && i.key_pressed(egui::Key::Z) && !i.modifiers.shift; - let redo = (i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::Z)) - || (i.modifiers.command && i.key_pressed(egui::Key::Y)); - (undo, redo) - }); - - if do_undo { - if let Some(previous) = self.history.undo(&self.state.library) { - self.state.library = previous; - self.previous_library_hash = Self::hash_library(&self.state.library); - self.render_pending = true; - } - } - if do_redo { - if let Some(next) = self.history.redo(&self.state.library) { - self.state.library = next; - self.previous_library_hash = Self::hash_library(&self.state.library); - self.render_pending = true; - } - } - - // Check for state changes and save to history - self.check_for_changes(); - } -} - -impl NodeBoxApp { - /// Handle FourPointHandle change (rect x, y, width, height). - fn handle_four_point_change(&mut self, x: f64, y: f64, width: f64, height: f64) { - if let Some(ref node_name) = self.state.selected_node { - if let Some(node) = self.state.library.root.child_mut(node_name) { - // Write to "position" Point port (per corevector.ndbx) - if let Some(port) = node.input_mut("position") { - port.value = nodebox_core::Value::Point(Point::new(x, y)); - } - if let Some(port) = node.input_mut("width") { - port.value = nodebox_core::Value::Float(width); - } - if let Some(port) = node.input_mut("height") { - port.value = nodebox_core::Value::Float(height); - } - } - } - } - - /// Handle parameter change from viewer handles. - fn handle_parameter_change(&mut self, param_name: &str, new_position: Point) { - if let Some(ref node_name) = self.state.selected_node { - if let Some(node) = self.state.library.root.child_mut(node_name) { - match param_name { - "position" => { - // Write to "position" Point port (per corevector.ndbx) - if let Some(port) = node.input_mut("position") { - port.value = nodebox_core::Value::Point(new_position); - } - } - "width" => { - // Get center from position port - let center_x = node.input("position") - .and_then(|p| p.value.as_point().cloned()) - .map(|p| p.x) - .unwrap_or(0.0); - let new_width = (new_position.x - center_x) * 2.0; - if let Some(width_port) = node.input_mut("width") { - width_port.value = nodebox_core::Value::Float(new_width.abs()); - } - } - "height" => { - // Get center from position port - let center_y = node.input("position") - .and_then(|p| p.value.as_point().cloned()) - .map(|p| p.y) - .unwrap_or(0.0); - let new_height = (new_position.y - center_y) * 2.0; - if let Some(height_port) = node.input_mut("height") { - height_port.value = nodebox_core::Value::Float(new_height.abs()); - } - } - "size" => { - // Get center from position port - let center = node.input("position") - .and_then(|p| p.value.as_point().cloned()) - .unwrap_or(Point::ZERO); - if let Some(width_port) = node.input_mut("width") { - width_port.value = nodebox_core::Value::Float((new_position.x - center.x).abs()); - } - if let Some(height_port) = node.input_mut("height") { - height_port.value = nodebox_core::Value::Float((new_position.y - center.y).abs()); - } - } - "point1" | "point2" => { - if let Some(port) = node.input_mut(param_name) { - port.value = nodebox_core::Value::Point(new_position); - } - } - _ => {} - } - } - } - } - - fn open_file(&mut self) { - if let Some(path) = rfd::FileDialog::new() - .add_filter("NodeBox Files", &["ndbx"]) - .pick_file() - { - if let Err(e) = self.state.load_file(&path) { - log::error!("Failed to load file: {}", e); - } - } - } - - fn save_file(&mut self) { - if let Some(ref path) = self.state.current_file.clone() { - if let Err(e) = self.state.save_file(path) { - log::error!("Failed to save file: {}", e); - } - } else { - self.save_file_as(); - } - } - - fn save_file_as(&mut self) { - if let Some(path) = rfd::FileDialog::new() - .add_filter("NodeBox Files", &["ndbx"]) - .save_file() - { - if let Err(e) = self.state.save_file(&path) { - log::error!("Failed to save file: {}", e); - } - } - } - - fn export_svg(&mut self) { - if let Some(path) = rfd::FileDialog::new() - .add_filter("SVG Files", &["svg"]) - .save_file() - { - // Use document dimensions for export - let width = self.state.library.width(); - let height = self.state.library.height(); - if let Err(e) = self.state.export_svg(&path, width, height) { - log::error!("Failed to export SVG: {}", e); - } - } - } - - fn export_png(&mut self) { - if let Some(path) = rfd::FileDialog::new() - .add_filter("PNG Files", &["png"]) - .save_file() - { - // Use document dimensions for export - let width = self.state.library.width() as u32; - let height = self.state.library.height() as u32; - - if let Err(e) = crate::export::export_png( - &self.state.geometry, - &path, - width, - height, - self.state.background_color, - ) { - log::error!("Failed to export PNG: {}", e); - } - } - } -} diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs deleted file mode 100644 index 684732b1..00000000 --- a/crates/nodebox-gui/src/eval.rs +++ /dev/null @@ -1,1809 +0,0 @@ -//! Network evaluation - executes node graphs to produce geometry. - -use std::collections::HashMap; -use nodebox_core::geometry::{Path, Point, Color, Contour, PathPoint, PointType}; -use nodebox_core::node::{Node, NodeLibrary}; -use nodebox_core::node::PortRange; -use nodebox_core::Value; - -/// The result of evaluating a node. -#[derive(Clone, Debug)] -pub enum NodeOutput { - /// No output (node not found or error). - None, - /// A single path. - Path(Path), - /// A list of paths. - Paths(Vec), - /// A single point. - Point(Point), - /// A list of points. - Points(Vec), - /// A float value. - Float(f64), - /// An integer value. - Int(i64), - /// A string value. - String(String), - /// A color value. - Color(Color), - /// A boolean value. - Boolean(bool), -} - -impl NodeOutput { - /// Convert to a list of paths (for rendering). - pub fn to_paths(&self) -> Vec { - match self { - NodeOutput::Path(p) => vec![p.clone()], - NodeOutput::Paths(ps) => ps.clone(), - NodeOutput::Point(pt) => { - // Convert a single point to a path with one point - let mut path = Path::new(); - path.fill = None; // Points don't have fill - let contour = Contour::from_points( - vec![PathPoint::new(pt.x, pt.y, PointType::LineTo)], - false, - ); - path.contours.push(contour); - vec![path] - } - NodeOutput::Points(pts) => { - // Convert points to a path where each point is in a single contour - // This allows the viewer's draw_points to render them - let mut path = Path::new(); - path.fill = None; // Points don't have fill - for pt in pts { - let contour = Contour::from_points( - vec![PathPoint::new(pt.x, pt.y, PointType::LineTo)], - false, - ); - path.contours.push(contour); - } - vec![path] - } - _ => Vec::new(), - } - } - - /// Get as a single path if available. - #[allow(dead_code)] - pub fn as_path(&self) -> Option<&Path> { - match self { - NodeOutput::Path(p) => Some(p), - _ => None, - } - } - - /// Get as paths (single or list). - #[allow(dead_code)] - pub fn as_paths(&self) -> Option> { - match self { - NodeOutput::Path(p) => Some(vec![p.clone()]), - NodeOutput::Paths(ps) => Some(ps.clone()), - _ => None, - } - } - - /// Convert any output to a list of individual values for list matching. - fn to_value_list(&self) -> Vec { - match self { - NodeOutput::None => vec![], - NodeOutput::Path(p) => vec![NodeOutput::Path(p.clone())], - NodeOutput::Paths(ps) => ps.iter().map(|p| NodeOutput::Path(p.clone())).collect(), - NodeOutput::Point(p) => vec![NodeOutput::Point(*p)], - NodeOutput::Points(pts) => pts.iter().map(|p| NodeOutput::Point(*p)).collect(), - v => vec![v.clone()], // Single values remain single - } - } - - /// Get the list length for this output (for list matching iteration count). - fn list_len(&self) -> usize { - match self { - NodeOutput::Paths(ps) => ps.len(), - NodeOutput::Points(pts) => pts.len(), - NodeOutput::None => 0, - _ => 1, - } - } -} - -/// Evaluate a node network and return the output of the rendered node. -pub fn evaluate_network(library: &NodeLibrary) -> Vec { - let network = &library.root; - - // Find the rendered child - let rendered_name = match &network.rendered_child { - Some(name) => name.clone(), - None => { - // No rendered child, return empty - return Vec::new(); - } - }; - - // Create a cache for node outputs - let mut cache: HashMap = HashMap::new(); - - // Evaluate the rendered node (this will recursively evaluate dependencies) - let output = evaluate_node(network, &rendered_name, &mut cache); - - output.to_paths() -} - -/// Determine how many times to execute the node for list matching. -/// Returns None if any VALUE-range input is empty. -fn compute_iteration_count( - inputs: &HashMap, - node: &Node, -) -> Option { - let mut max_size = 1usize; - - // Check inputs that have corresponding port definitions with range info - for port in &node.inputs { - if port.range == PortRange::List { - continue; // LIST-range ports don't contribute to iteration count - } - if let Some(output) = inputs.get(&port.name) { - let size = output.list_len(); - if size == 0 { - return None; // Empty list → no output - } - max_size = max_size.max(size); - } - } - - // Also check inputs that don't have port definitions (from connections) - // These are treated as VALUE-range by default - for (name, output) in inputs { - // Skip if we already processed this port above - if node.inputs.iter().any(|p| &p.name == name) { - continue; - } - let size = output.list_len(); - if size == 0 { - return None; - } - max_size = max_size.max(size); - } - - Some(max_size) -} - -/// Build inputs for a single iteration with wrapping. -fn build_iteration_inputs( - inputs: &HashMap, - node: &Node, - iteration: usize, -) -> HashMap { - let mut result = HashMap::new(); - - for (name, output) in inputs { - // Check if there's a port definition for this input - let port = node.inputs.iter().find(|p| &p.name == name); - let is_list_range = port.map_or(false, |p| p.range == PortRange::List); - - let value = if is_list_range { - output.clone() // Pass entire list for LIST-range ports - } else { - let list = output.to_value_list(); - if list.is_empty() { - NodeOutput::None - } else { - list[iteration % list.len()].clone() // Wrap - } - }; - result.insert(name.clone(), value); - } - result -} - -/// Combine results from multiple iterations. -fn collect_results(results: Vec) -> NodeOutput { - if results.is_empty() { - return NodeOutput::None; - } - if results.len() == 1 { - return results.into_iter().next().unwrap(); - } - - // Collect as Paths (most common case for geometry operations) - let paths: Vec = results.into_iter() - .flat_map(|r| r.to_paths()) - .collect(); - - if paths.is_empty() { - NodeOutput::None - } else { - NodeOutput::Paths(paths) - } -} - -/// Evaluate a single node, recursively evaluating its dependencies. -fn evaluate_node( - network: &Node, - node_name: &str, - cache: &mut HashMap, -) -> NodeOutput { - // Check cache first - if let Some(output) = cache.get(node_name) { - return output.clone(); - } - - // Find the node - let node = match network.child(node_name) { - Some(n) => n, - None => return NodeOutput::None, - }; - - // Collect input values for this node - let mut inputs: HashMap = HashMap::new(); - - // For each input port, check if there are connections - for port in &node.inputs { - // Get ALL connections to this port (for merge/combine operations) - let connections: Vec<_> = network.connections - .iter() - .filter(|c| c.input_node == node_name && c.input_port == port.name) - .collect(); - - if connections.is_empty() { - // No connections - use the port's default value - inputs.insert(port.name.clone(), value_to_output(&port.value)); - } else if connections.len() == 1 { - // Single connection - evaluate and use directly - let upstream_output = evaluate_node(network, &connections[0].output_node, cache); - inputs.insert(port.name.clone(), upstream_output); - } else { - // Multiple connections - collect all outputs as paths - let mut all_paths: Vec = Vec::new(); - for conn in connections { - let upstream_output = evaluate_node(network, &conn.output_node, cache); - all_paths.extend(upstream_output.to_paths()); - } - inputs.insert(port.name.clone(), NodeOutput::Paths(all_paths)); - } - } - - // Also collect inputs from connections that don't have corresponding port definitions - // This handles nodes loaded from ndbx files that may not have all ports defined - for conn in &network.connections { - if conn.input_node == node_name && !inputs.contains_key(&conn.input_port) { - // Check if there are multiple connections to this port - let all_conns: Vec<_> = network.connections - .iter() - .filter(|c| c.input_node == node_name && c.input_port == conn.input_port) - .collect(); - - if all_conns.len() == 1 { - let upstream_output = evaluate_node(network, &conn.output_node, cache); - inputs.insert(conn.input_port.clone(), upstream_output); - } else { - // Multiple connections - collect all outputs as paths - let mut all_paths: Vec = Vec::new(); - for c in all_conns { - let upstream_output = evaluate_node(network, &c.output_node, cache); - all_paths.extend(upstream_output.to_paths()); - } - inputs.insert(conn.input_port.clone(), NodeOutput::Paths(all_paths)); - } - } - } - - // Determine iteration count for list matching - let iteration_count = compute_iteration_count(&inputs, node); - - let output = match iteration_count { - None => NodeOutput::None, // Empty list input - Some(1) => execute_node(node, &inputs), // Single iteration (optimization) - Some(count) => { - // Multiple iterations: list matching - let mut results = Vec::with_capacity(count); - for i in 0..count { - let iter_inputs = build_iteration_inputs(&inputs, node, i); - let result = execute_node(node, &iter_inputs); - results.push(result); - } - collect_results(results) - } - }; - - // Cache and return - cache.insert(node_name.to_string(), output.clone()); - output -} - -/// Convert a Value to a NodeOutput. -fn value_to_output(value: &Value) -> NodeOutput { - match value { - Value::Float(f) => NodeOutput::Float(*f), - Value::Int(i) => NodeOutput::Int(*i), - Value::String(s) => NodeOutput::String(s.clone()), - Value::Boolean(b) => NodeOutput::Boolean(*b), - Value::Point(p) => NodeOutput::Point(*p), - Value::Color(c) => NodeOutput::Color(*c), - Value::Geometry(_) => NodeOutput::None, // Will be filled by connections - Value::List(_) => NodeOutput::None, // TODO: handle lists - Value::Null => NodeOutput::None, - Value::Path(p) => NodeOutput::Path(p.clone()), - Value::Map(_) => NodeOutput::None, // TODO: handle maps - } -} - -/// Get a float input value. -fn get_float(inputs: &HashMap, name: &str, default: f64) -> f64 { - match inputs.get(name) { - Some(NodeOutput::Float(f)) => *f, - Some(NodeOutput::Int(i)) => *i as f64, - _ => default, - } -} - -/// Get an integer input value. -fn get_int(inputs: &HashMap, name: &str, default: i64) -> i64 { - match inputs.get(name) { - Some(NodeOutput::Int(i)) => *i, - Some(NodeOutput::Float(f)) => *f as i64, - _ => default, - } -} - -/// Get a point input value. -fn get_point(inputs: &HashMap, name: &str, default: Point) -> Point { - match inputs.get(name) { - Some(NodeOutput::Point(p)) => *p, - Some(NodeOutput::Points(pts)) if !pts.is_empty() => pts[0], // Fallback for safety - _ => default, - } -} - -/// Get a color input value. -fn get_color(inputs: &HashMap, name: &str, default: Color) -> Color { - match inputs.get(name) { - Some(NodeOutput::Color(c)) => *c, - _ => default, - } -} - -/// Get a path input value. -fn get_path(inputs: &HashMap, name: &str) -> Option { - match inputs.get(name) { - Some(NodeOutput::Path(p)) => Some(p.clone()), - Some(NodeOutput::Paths(ps)) if !ps.is_empty() => Some(ps[0].clone()), - _ => None, - } -} - -/// Get paths input value (for merge/combine operations). -fn get_paths(inputs: &HashMap, name: &str) -> Vec { - match inputs.get(name) { - Some(NodeOutput::Path(p)) => vec![p.clone()], - Some(NodeOutput::Paths(ps)) => ps.clone(), - _ => Vec::new(), - } -} - -/// Get a boolean input value. -fn get_bool(inputs: &HashMap, name: &str, default: bool) -> bool { - match inputs.get(name) { - Some(NodeOutput::Boolean(b)) => *b, - _ => default, - } -} - -/// Get a string input value. -fn get_string(inputs: &HashMap, name: &str, default: &str) -> String { - match inputs.get(name) { - Some(NodeOutput::String(s)) => s.clone(), - _ => default.to_string(), - } -} - -/// Execute a node and return its output. -fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput { - // Get the function name (prototype determines what the node does) - let proto = match &node.prototype { - Some(p) => p.as_str(), - None => return NodeOutput::None, - }; - - match proto { - // Geometry generators - // Note: These use "position" (Point) as per corevector.ndbx library definition - "corevector.ellipse" => { - let position = get_point(inputs, "position", Point::ZERO); - let width = get_float(inputs, "width", 100.0); - let height = get_float(inputs, "height", 100.0); - let path = nodebox_ops::ellipse(position, width, height); - NodeOutput::Path(path) - } - "corevector.rect" => { - let position = get_point(inputs, "position", Point::ZERO); - let width = get_float(inputs, "width", 100.0); - let height = get_float(inputs, "height", 100.0); - // Note: corevector.ndbx uses "roundness" (Point), not rx/ry - let roundness = get_point(inputs, "roundness", Point::ZERO); - let path = nodebox_ops::rect(position, width, height, roundness); - NodeOutput::Path(path) - } - "corevector.line" => { - let p1 = get_point(inputs, "point1", Point::ZERO); - let p2 = get_point(inputs, "point2", Point::new(100.0, 100.0)); - let points = get_int(inputs, "points", 2) as u32; - let path = nodebox_ops::line(p1, p2, points); - NodeOutput::Path(path) - } - "corevector.polygon" => { - let position = get_point(inputs, "position", Point::ZERO); - let radius = get_float(inputs, "radius", 50.0); - let sides = get_int(inputs, "sides", 6) as u32; - let align = get_bool(inputs, "align", true); - let path = nodebox_ops::polygon(position, radius, sides, align); - NodeOutput::Path(path) - } - "corevector.star" => { - let position = get_point(inputs, "position", Point::ZERO); - let points = get_int(inputs, "points", 5) as u32; - let outer = get_float(inputs, "outer", 50.0); - let inner = get_float(inputs, "inner", 25.0); - let path = nodebox_ops::star(position, points, outer, inner); - NodeOutput::Path(path) - } - "corevector.arc" => { - let position = get_point(inputs, "position", Point::ZERO); - let width = get_float(inputs, "width", 100.0); - let height = get_float(inputs, "height", 100.0); - // Note: corevector.ndbx uses "start_angle" (underscore), not "startAngle" - let start_angle = get_float(inputs, "start_angle", 0.0); - let degrees = get_float(inputs, "degrees", 90.0); - let arc_type = get_string(inputs, "type", "pie"); - let path = nodebox_ops::arc(position, width, height, start_angle, degrees, &arc_type); - NodeOutput::Path(path) - } - - // Filters/transforms - "corevector.colorize" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let fill = get_color(inputs, "fill", Color::WHITE); - let stroke = get_color(inputs, "stroke", Color::BLACK); - let stroke_width = get_float(inputs, "strokeWidth", 1.0); - let path = nodebox_ops::colorize(&shape, fill, stroke, stroke_width); - NodeOutput::Path(path) - } - "corevector.translate" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let offset = get_point(inputs, "translate", Point::ZERO); - let path = nodebox_ops::translate(&shape, offset); - NodeOutput::Path(path) - } - "corevector.rotate" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let angle = get_float(inputs, "angle", 0.0); - let origin = get_point(inputs, "origin", Point::ZERO); - let path = nodebox_ops::rotate(&shape, angle, origin); - NodeOutput::Path(path) - } - "corevector.scale" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let scale = get_point(inputs, "scale", Point::new(100.0, 100.0)); - let origin = get_point(inputs, "origin", Point::ZERO); - let path = nodebox_ops::scale(&shape, scale, origin); - NodeOutput::Path(path) - } - "corevector.align" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let position = get_point(inputs, "position", Point::ZERO); - let halign = get_string(inputs, "halign", "center"); - let valign = get_string(inputs, "valign", "middle"); - let path = nodebox_ops::align_str(&shape, position, &halign, &valign); - NodeOutput::Path(path) - } - "corevector.fit" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - // Note: corevector.ndbx uses "position" (Point) and "keep_proportions" (underscore) - let position = get_point(inputs, "position", Point::ZERO); - let width = get_float(inputs, "width", 100.0); - let height = get_float(inputs, "height", 100.0); - let keep_proportions = get_bool(inputs, "keep_proportions", true); - let path = nodebox_ops::fit(&shape, position, width, height, keep_proportions); - NodeOutput::Path(path) - } - "corevector.copy" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let copies = get_int(inputs, "copies", 1) as u32; - let order = nodebox_ops::CopyOrder::from_str(&get_string(inputs, "order", "tsr")); - // Note: corevector.ndbx uses "translate" (Point) and "scale" (Point) - let translate = get_point(inputs, "translate", Point::ZERO); - let rotate = get_float(inputs, "rotate", 0.0); - let scale = get_point(inputs, "scale", Point::new(100.0, 100.0)); - let paths = nodebox_ops::copy(&shape, copies, order, translate, rotate, scale); - NodeOutput::Paths(paths) - } - - // Combine operations - "corevector.merge" | "corevector.combine" => { - // Merge/combine takes multiple shapes and combines them - let shapes = get_paths(inputs, "shapes"); - if shapes.is_empty() { - // Try "shape" port as fallback - let shape = get_paths(inputs, "shape"); - if shape.is_empty() { - return NodeOutput::None; - } - return NodeOutput::Paths(shape); - } - NodeOutput::Paths(shapes) - } - - // List combine - combines multiple lists into one - "list.combine" => { - let mut all_paths: Vec = Vec::new(); - // Collect from list1 through list5 - for port_name in ["list1", "list2", "list3", "list4", "list5"] { - let paths = get_paths(inputs, port_name); - all_paths.extend(paths); - } - if all_paths.is_empty() { - NodeOutput::None - } else { - NodeOutput::Paths(all_paths) - } - } - - // Resample - "corevector.resample" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let points = get_int(inputs, "points", 20) as usize; - let path = nodebox_ops::resample(&shape, points); - NodeOutput::Path(path) - } - - // Wiggle - "corevector.wiggle" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let scope = nodebox_ops::WiggleScope::from_str(&get_string(inputs, "scope", "points")); - // Note: corevector.ndbx uses "offset" (Point), not offsetX/offsetY - let offset = get_point(inputs, "offset", Point::new(10.0, 10.0)); - let seed = get_int(inputs, "seed", 0) as u64; - let path = nodebox_ops::wiggle(&shape, scope, offset, seed); - NodeOutput::Path(path) - } - - // Connect points - "corevector.connect" => { - // Get points from input - let closed = get_bool(inputs, "closed", false); - match inputs.get("points") { - Some(NodeOutput::Points(pts)) => { - let path = nodebox_ops::connect(pts, closed); - NodeOutput::Path(path) - } - _ => NodeOutput::None, - } - } - - // Grid of points - "corevector.grid" => { - let columns = get_int(inputs, "columns", 3) as u32; - let rows = get_int(inputs, "rows", 3) as u32; - let width = get_float(inputs, "width", 100.0); - let height = get_float(inputs, "height", 100.0); - // Note: corevector.ndbx uses "position" (Point), not x/y - let position = get_point(inputs, "position", Point::ZERO); - let points = nodebox_ops::grid(columns, rows, width, height, position); - NodeOutput::Points(points) - } - - // Make point - "corevector.point" | "corevector.makePoint" | "corevector.make_point" => { - let x = get_float(inputs, "x", 0.0); - let y = get_float(inputs, "y", 0.0); - NodeOutput::Point(Point::new(x, y)) - } - - // Reflect - "corevector.reflect" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - // Note: corevector.ndbx uses "position" (Point), "angle", "keep_original" - let position = get_point(inputs, "position", Point::ZERO); - let angle = get_float(inputs, "angle", 0.0); - let keep_original = get_bool(inputs, "keep_original", true); - let geometry = nodebox_ops::reflect(&shape, position, angle, keep_original); - NodeOutput::Paths(nodebox_ops::ungroup(&geometry)) - } - - // Skew - "corevector.skew" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - // Note: corevector.ndbx uses "skew" (Point), "origin" (Point) - let skew = get_point(inputs, "skew", Point::ZERO); - let origin = get_point(inputs, "origin", Point::ZERO); - let path = nodebox_ops::skew(&shape, skew, origin); - NodeOutput::Path(path) - } - - // Snap to grid - "corevector.snap" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - // Note: corevector.ndbx uses "distance" (float), "strength" (float), "position" (Point) - let distance = get_float(inputs, "distance", 10.0); - let strength = get_float(inputs, "strength", 1.0); - let position = get_point(inputs, "position", Point::ZERO); - let path = nodebox_ops::snap(&shape, distance, strength, position); - NodeOutput::Path(path) - } - - // Point on path - "corevector.point_on_path" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let t = get_float(inputs, "t", 0.0); - // Range varies; convert from 0-100 percentage to 0-1 if needed - let t_normalized = if t > 1.0 { t / 100.0 } else { t }; - let point = nodebox_ops::point_on_path(&shape, t_normalized); - NodeOutput::Point(point) - } - - // Centroid - "corevector.centroid" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let point = nodebox_ops::centroid(&shape); - NodeOutput::Point(point) - } - - // Line from angle - "corevector.line_angle" => { - let position = get_point(inputs, "position", Point::ZERO); - let angle = get_float(inputs, "angle", 0.0); - let distance = get_float(inputs, "distance", 100.0); - let points = get_int(inputs, "points", 2) as u32; - let path = nodebox_ops::line_angle(position, angle, distance, points); - NodeOutput::Path(path) - } - - // Quad curve - "corevector.quad_curve" => { - let point1 = get_point(inputs, "point1", Point::ZERO); - let point2 = get_point(inputs, "point2", Point::new(100.0, 100.0)); - let t = get_float(inputs, "t", 0.5); - let distance = get_float(inputs, "distance", 50.0); - let path = nodebox_ops::quad_curve(point1, point2, t, distance); - NodeOutput::Path(path) - } - - // Scatter points - "corevector.scatter" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let amount = get_int(inputs, "amount", 10) as usize; - let seed = get_int(inputs, "seed", 0) as u64; - let points = nodebox_ops::scatter(&shape, amount, seed); - NodeOutput::Points(points) - } - - // Stack - "corevector.stack" => { - let shapes = get_paths(inputs, "shapes"); - if shapes.is_empty() { - return NodeOutput::None; - } - let direction = get_string(inputs, "direction", "east"); - let margin = get_float(inputs, "margin", 0.0); - let dir = match direction.as_str() { - "north" => nodebox_ops::StackDirection::North, - "south" => nodebox_ops::StackDirection::South, - "west" => nodebox_ops::StackDirection::West, - _ => nodebox_ops::StackDirection::East, - }; - let paths = nodebox_ops::stack(&shapes, dir, margin); - NodeOutput::Paths(paths) - } - - // Freehand path - "corevector.freehand" => { - let path_string = get_string(inputs, "path", ""); - let path = nodebox_ops::freehand(&path_string); - NodeOutput::Path(path) - } - - // Link shapes - "corevector.link" => { - let shape1 = match get_path(inputs, "shape1") { - Some(p) => p, - None => return NodeOutput::None, - }; - let shape2 = match get_path(inputs, "shape2") { - Some(p) => p, - None => return NodeOutput::None, - }; - let orientation = get_string(inputs, "orientation", "horizontal"); - let horizontal = orientation == "horizontal"; - let path = nodebox_ops::link(&shape1, &shape2, horizontal); - NodeOutput::Path(path) - } - - // Group - "corevector.group" => { - let shapes = get_paths(inputs, "shapes"); - let geometry = nodebox_ops::group(&shapes); - NodeOutput::Paths(nodebox_ops::ungroup(&geometry)) - } - - // Ungroup - "corevector.ungroup" => { - // Ungroup expects a Geometry, but we work with paths - let shapes = get_paths(inputs, "geometry"); - if shapes.is_empty() { - let shape = get_paths(inputs, "shape"); - return NodeOutput::Paths(shape); - } - NodeOutput::Paths(shapes) - } - - // Fit to another shape - "corevector.fit_to" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let bounding = match get_path(inputs, "bounding") { - Some(p) => p, - None => return NodeOutput::None, - }; - let keep_proportions = get_bool(inputs, "keep_proportions", true); - let path = nodebox_ops::fit_to(&shape, &bounding, keep_proportions); - NodeOutput::Path(path) - } - - // Delete - "corevector.delete" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; - let bounding = match get_path(inputs, "bounding") { - Some(p) => p, - None => return NodeOutput::Path(shape), - }; - let scope = get_string(inputs, "scope", "points"); - let delete_scope = match scope.as_str() { - "paths" => nodebox_ops::DeleteScope::Paths, - _ => nodebox_ops::DeleteScope::Points, - }; - let operation = get_string(inputs, "operation", "selected"); - let delete_inside = operation == "selected"; - let path = nodebox_ops::delete(&shape, &bounding, delete_scope, delete_inside); - NodeOutput::Path(path) - } - - // Sort - "corevector.sort" => { - let shapes = get_paths(inputs, "shapes"); - if shapes.is_empty() { - return NodeOutput::None; - } - let order_by = get_string(inputs, "order_by", "x"); - let sort_by = match order_by.as_str() { - "y" => nodebox_ops::SortBy::Y, - "distance" => nodebox_ops::SortBy::Distance, - "angle" => nodebox_ops::SortBy::Angle, - _ => nodebox_ops::SortBy::X, - }; - let position = get_point(inputs, "position", Point::ZERO); - let paths = nodebox_ops::sort_paths(&shapes, sort_by, position); - NodeOutput::Paths(paths) - } - - // Default: pass-through or unknown node - _ => { - // For unknown nodes, try to pass through a shape input - if let Some(path) = get_path(inputs, "shape") { - NodeOutput::Path(path) - } else if let Some(path) = get_path(inputs, "shapes") { - NodeOutput::Path(path) - } else { - log::warn!("Unknown node prototype: {}", proto); - NodeOutput::None - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use nodebox_core::node::{Port, Connection, PortRange}; - - #[test] - fn test_evaluate_simple_ellipse() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(100.0, 100.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_rendered_child("ellipse1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - assert!((bounds.width - 50.0).abs() < 0.1); - assert!((bounds.height - 50.0).abs() < 0.1); - } - - #[test] - fn test_evaluate_colorized_ellipse() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(100.0, 100.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_child( - Node::new("colorize1") - .with_prototype("corevector.colorize") - .with_input(Port::geometry("shape")) - .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))) - .with_input(Port::color("stroke", Color::BLACK)) - .with_input(Port::float("strokeWidth", 2.0)) - ) - .with_connection(Connection::new("ellipse1", "colorize1", "shape")) - .with_rendered_child("colorize1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - // Check that the colorize was applied - assert!(paths[0].fill.is_some()); - let fill = paths[0].fill.unwrap(); - assert!((fill.r - 1.0).abs() < 0.01); - assert!(fill.g < 0.01); - assert!(fill.b < 0.01); - } - - #[test] - fn test_evaluate_merged_shapes() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::new(100.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_child( - Node::new("merge1") - .with_prototype("corevector.merge") - .with_input(Port::geometry("shapes")) - ) - .with_connection(Connection::new("ellipse1", "merge1", "shapes")) - .with_connection(Connection::new("rect1", "merge1", "shapes")) - .with_rendered_child("merge1"); - - let paths = evaluate_network(&library); - // Merge collects all connected shapes - assert_eq!(paths.len(), 2); - } - - #[test] - fn test_evaluate_rect() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 80.0)) - .with_input(Port::float("height", 40.0)) - ) - .with_rendered_child("rect1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - assert!((bounds.width - 80.0).abs() < 0.1); - assert!((bounds.height - 40.0).abs() < 0.1); - } - - #[test] - fn test_evaluate_line() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("line1") - .with_prototype("corevector.line") - .with_input(Port::point("point1", Point::new(0.0, 0.0))) - .with_input(Port::point("point2", Point::new(100.0, 50.0))) - .with_input(Port::int("points", 2)) - ) - .with_rendered_child("line1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - assert!((bounds.width - 100.0).abs() < 0.1); - assert!((bounds.height - 50.0).abs() < 0.1); - } - - #[test] - fn test_evaluate_polygon() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("polygon1") - .with_prototype("corevector.polygon") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("radius", 50.0)) - .with_input(Port::int("sides", 6)) - .with_input(Port::boolean("align", true)) - ) - .with_rendered_child("polygon1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - // Hexagon with radius 50 should have bounds approximately 100x86 (2*r x sqrt(3)*r) - let bounds = paths[0].bounds().unwrap(); - assert!(bounds.width > 80.0 && bounds.width < 110.0); - assert!(bounds.height > 80.0 && bounds.height < 110.0); - } - - #[test] - fn test_evaluate_star() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("star1") - .with_prototype("corevector.star") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::int("points", 5)) - .with_input(Port::float("outer", 50.0)) - .with_input(Port::float("inner", 25.0)) - ) - .with_rendered_child("star1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - // Star with outer radius 50 should have bounds approximately 100x100 - let bounds = paths[0].bounds().unwrap(); - assert!(bounds.width > 80.0 && bounds.width < 110.0); - } - - #[test] - fn test_evaluate_arc() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("arc1") - .with_prototype("corevector.arc") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::float("start_angle", 0.0)) - .with_input(Port::float("degrees", 180.0)) - .with_input(Port::string("type", "pie")) - ) - .with_rendered_child("arc1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - } - - #[test] - fn test_evaluate_translate() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_child( - Node::new("translate1") - .with_prototype("corevector.translate") - .with_input(Port::geometry("shape")) - .with_input(Port::point("translate", Point::new(100.0, 50.0))) - ) - .with_connection(Connection::new("ellipse1", "translate1", "shape")) - .with_rendered_child("translate1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - // Original ellipse centered at (0,0) translated by (100, 50) - // Center should now be at (100, 50) - let center_x = bounds.x + bounds.width / 2.0; - let center_y = bounds.y + bounds.height / 2.0; - assert!((center_x - 100.0).abs() < 1.0); - assert!((center_y - 50.0).abs() < 1.0); - } - - #[test] - fn test_evaluate_scale() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - ) - .with_child( - Node::new("scale1") - .with_prototype("corevector.scale") - .with_input(Port::geometry("shape")) - .with_input(Port::point("scale", Point::new(50.0, 200.0))) // 50% x, 200% y - .with_input(Port::point("origin", Point::ZERO)) - ) - .with_connection(Connection::new("ellipse1", "scale1", "shape")) - .with_rendered_child("scale1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - // Width should be 50, height should be 200 - assert!((bounds.width - 50.0).abs() < 1.0); - assert!((bounds.height - 200.0).abs() < 1.0); - } - - #[test] - fn test_evaluate_copy() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_child( - Node::new("copy1") - .with_prototype("corevector.copy") - .with_input(Port::geometry("shape")) - .with_input(Port::int("copies", 3)) - .with_input(Port::string("order", "tsr")) - .with_input(Port::point("translate", Point::new(60.0, 0.0))) - .with_input(Port::float("rotate", 0.0)) - .with_input(Port::point("scale", Point::new(100.0, 100.0))) - ) - .with_connection(Connection::new("ellipse1", "copy1", "shape")) - .with_rendered_child("copy1"); - - let paths = evaluate_network(&library); - // Should have 3 copies - assert_eq!(paths.len(), 3); - } - - #[test] - fn test_evaluate_empty_network() { - let library = NodeLibrary::new("test"); - let paths = evaluate_network(&library); - assert!(paths.is_empty()); - } - - #[test] - fn test_evaluate_no_rendered_child() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ); - // No rendered_child set - - let paths = evaluate_network(&library); - assert!(paths.is_empty()); - } - - #[test] - fn test_evaluate_colorize_without_input() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("colorize1") - .with_prototype("corevector.colorize") - .with_input(Port::geometry("shape")) - .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))) - .with_input(Port::color("stroke", Color::BLACK)) - .with_input(Port::float("strokeWidth", 2.0)) - ) - .with_rendered_child("colorize1"); - - // Should handle missing input gracefully - let paths = evaluate_network(&library); - assert!(paths.is_empty()); - } - - #[test] - fn test_evaluate_unknown_node_type() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("unknown1") - .with_prototype("corevector.nonexistent") - ) - .with_rendered_child("unknown1"); - - // Should handle unknown node type gracefully - let paths = evaluate_network(&library); - assert!(paths.is_empty()); - } - - #[test] - fn test_evaluate_resample() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - ) - .with_child( - Node::new("resample1") - .with_prototype("corevector.resample") - .with_input(Port::geometry("shape")) - .with_input(Port::int("points", 20)) - ) - .with_connection(Connection::new("ellipse1", "resample1", "shape")) - .with_rendered_child("resample1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - // Resampled path should have the specified number of points - // Note: exact point count depends on implementation - } - - #[test] - fn test_evaluate_grid() { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("grid1") - .with_prototype("corevector.grid") - .with_input(Port::int("columns", 3)) - .with_input(Port::int("rows", 3)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::point("position", Point::ZERO)) - ) - .with_child( - Node::new("connect1") - .with_prototype("corevector.connect") - // points port expects entire list, not individual values - .with_input(Port::geometry("points").with_port_range(PortRange::List)) - .with_input(Port::boolean("closed", false)) - ) - .with_connection(Connection::new("grid1", "connect1", "points")) - .with_rendered_child("connect1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - } - - // ========================================================================= - // Tests for correct port names (matching corevector.ndbx library) - // These tests verify that nodes use "position" (Point) instead of x/y - // ========================================================================= - - #[test] - fn test_ellipse_with_position_port() { - // According to corevector.ndbx, ellipse should use "position" (Point), not x/y - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(100.0, 50.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_rendered_child("ellipse1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - // Ellipse centered at (100, 50) with width/height 50 - // Bounds should be approximately (75, 25) to (125, 75) - let center_x = bounds.x + bounds.width / 2.0; - let center_y = bounds.y + bounds.height / 2.0; - assert!((center_x - 100.0).abs() < 1.0, "Center X should be 100, got {}", center_x); - assert!((center_y - 50.0).abs() < 1.0, "Center Y should be 50, got {}", center_y); - } - - #[test] - fn test_rect_with_position_port() { - // According to corevector.ndbx, rect should use "position" (Point), not x/y - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::new(-50.0, 25.0))) - .with_input(Port::float("width", 80.0)) - .with_input(Port::float("height", 40.0)) - ) - .with_rendered_child("rect1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - let center_x = bounds.x + bounds.width / 2.0; - let center_y = bounds.y + bounds.height / 2.0; - assert!((center_x - (-50.0)).abs() < 1.0, "Center X should be -50, got {}", center_x); - assert!((center_y - 25.0).abs() < 1.0, "Center Y should be 25, got {}", center_y); - } - - #[test] - fn test_rect_with_roundness_port() { - // According to corevector.ndbx, rect should use "roundness" (Point), not rx/ry - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::new(0.0, 0.0))) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::point("roundness", Point::new(10.0, 10.0))) - ) - .with_rendered_child("rect1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - // If roundness is applied, the path should have more points than a simple rect - } - - #[test] - fn test_polygon_with_position_port() { - // According to corevector.ndbx, polygon should use "position" (Point), not x/y - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("polygon1") - .with_prototype("corevector.polygon") - .with_input(Port::point("position", Point::new(200.0, -100.0))) - .with_input(Port::float("radius", 50.0)) - .with_input(Port::int("sides", 6)) - .with_input(Port::boolean("align", true)) - ) - .with_rendered_child("polygon1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - let center_x = bounds.x + bounds.width / 2.0; - let center_y = bounds.y + bounds.height / 2.0; - assert!((center_x - 200.0).abs() < 1.0, "Center X should be 200, got {}", center_x); - assert!((center_y - (-100.0)).abs() < 1.0, "Center Y should be -100, got {}", center_y); - } - - #[test] - fn test_star_with_position_port() { - // According to corevector.ndbx, star should use "position" (Point), not x/y - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("star1") - .with_prototype("corevector.star") - .with_input(Port::point("position", Point::new(75.0, 75.0))) - .with_input(Port::int("points", 5)) - .with_input(Port::float("outer", 50.0)) - .with_input(Port::float("inner", 25.0)) - ) - .with_rendered_child("star1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - let center_x = bounds.x + bounds.width / 2.0; - let center_y = bounds.y + bounds.height / 2.0; - // Star geometry may not be perfectly symmetric, allow some tolerance - assert!((center_x - 75.0).abs() < 10.0, "Center X should be near 75, got {}", center_x); - assert!((center_y - 75.0).abs() < 10.0, "Center Y should be near 75, got {}", center_y); - } - - #[test] - fn test_arc_with_position_and_start_angle() { - // According to corevector.ndbx, arc uses "position" and "start_angle" (underscore) - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("arc1") - .with_prototype("corevector.arc") - .with_input(Port::point("position", Point::new(50.0, -50.0))) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::float("start_angle", 0.0)) - .with_input(Port::float("degrees", 180.0)) - .with_input(Port::string("type", "pie")) - ) - .with_rendered_child("arc1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - let center_x = bounds.x + bounds.width / 2.0; - // Arc center should be near (50, -50) - assert!((center_x - 50.0).abs() < 10.0, "Center X should be near 50, got {}", center_x); - } - - #[test] - fn test_copy_with_translate_and_scale_points() { - // According to corevector.ndbx, copy uses "translate" (Point) and "scale" (Point) - // not tx/ty and sx/sy - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(0.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - ) - .with_child( - Node::new("copy1") - .with_prototype("corevector.copy") - .with_input(Port::geometry("shape")) - .with_input(Port::int("copies", 3)) - .with_input(Port::string("order", "tsr")) - .with_input(Port::point("translate", Point::new(60.0, 0.0))) - .with_input(Port::float("rotate", 0.0)) - .with_input(Port::point("scale", Point::new(100.0, 100.0))) - ) - .with_connection(Connection::new("ellipse1", "copy1", "shape")) - .with_rendered_child("copy1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 3, "Should have 3 copies"); - - // First copy at x=0, second at x=60, third at x=120 - // Check that copies are actually spread out - let bounds0 = paths[0].bounds().unwrap(); - let bounds2 = paths[2].bounds().unwrap(); - let center0_x = bounds0.x + bounds0.width / 2.0; - let center2_x = bounds2.x + bounds2.width / 2.0; - assert!((center2_x - center0_x - 120.0).abs() < 1.0, - "Third copy should be 120 units from first, got {}", center2_x - center0_x); - } - - #[test] - fn test_grid_with_position_port() { - // According to corevector.ndbx, grid uses "position" (Point), not x/y - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("grid1") - .with_prototype("corevector.grid") - .with_input(Port::int("columns", 3)) - .with_input(Port::int("rows", 3)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::point("position", Point::new(50.0, 50.0))) - ) - .with_child( - Node::new("connect1") - .with_prototype("corevector.connect") - // points port expects entire list, not individual values - .with_input(Port::geometry("points").with_port_range(PortRange::List)) - .with_input(Port::boolean("closed", false)) - ) - .with_connection(Connection::new("grid1", "connect1", "points")) - .with_rendered_child("connect1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - let bounds = paths[0].bounds().unwrap(); - let center_x = bounds.x + bounds.width / 2.0; - let center_y = bounds.y + bounds.height / 2.0; - assert!((center_x - 50.0).abs() < 1.0, "Center X should be 50, got {}", center_x); - assert!((center_y - 50.0).abs() < 1.0, "Center Y should be 50, got {}", center_y); - } - - #[test] - fn test_wiggle_with_offset_point() { - // According to corevector.ndbx, wiggle uses "offset" (Point), not offsetX/offsetY - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(0.0, 0.0))) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - ) - .with_child( - Node::new("wiggle1") - .with_prototype("corevector.wiggle") - .with_input(Port::geometry("shape")) - .with_input(Port::string("scope", "points")) - .with_input(Port::point("offset", Point::new(10.0, 10.0))) - .with_input(Port::int("seed", 42)) - ) - .with_connection(Connection::new("ellipse1", "wiggle1", "shape")) - .with_rendered_child("wiggle1"); - - let paths = evaluate_network(&library); - assert!(!paths.is_empty(), "Wiggle should produce output"); - } - - #[test] - fn test_fit_with_position_and_keep_proportions() { - // According to corevector.ndbx, fit uses "position" (Point) and "keep_proportions" - // Test that fit reads from position port (not x/y) and keep_proportions (not keepProportions) - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(0.0, 0.0))) - .with_input(Port::float("width", 200.0)) - .with_input(Port::float("height", 100.0)) - ) - .with_child( - Node::new("fit1") - .with_prototype("corevector.fit") - .with_input(Port::geometry("shape")) - .with_input(Port::point("position", Point::new(100.0, 100.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)) - .with_input(Port::boolean("keep_proportions", true)) - ) - .with_connection(Connection::new("ellipse1", "fit1", "shape")) - .with_rendered_child("fit1"); - - let paths = evaluate_network(&library); - assert_eq!(paths.len(), 1); - - // Verify fit produced output - the shape should be constrained to max 50x50 - // With keep_proportions=true and input 200x100, output should be 50x25 - let bounds = paths[0].bounds().unwrap(); - assert!(bounds.width <= 51.0, "Width should be at most 50, got {}", bounds.width); - assert!(bounds.height <= 51.0, "Height should be at most 50, got {}", bounds.height); - } - - #[test] - fn test_node_output_conversions() { - // Test to_paths() - let path = Path::new(); - let output = NodeOutput::Path(path.clone()); - assert_eq!(output.to_paths().len(), 1); - - let output = NodeOutput::Paths(vec![path.clone(), path.clone()]); - assert_eq!(output.to_paths().len(), 2); - - let output = NodeOutput::Float(1.0); - assert!(output.to_paths().is_empty()); - - // Test as_path() - let output = NodeOutput::Path(path.clone()); - assert!(output.as_path().is_some()); - - let output = NodeOutput::Float(1.0); - assert!(output.as_path().is_none()); - - // Test as_paths() - let output = NodeOutput::Path(path.clone()); - assert!(output.as_paths().is_some()); - assert_eq!(output.as_paths().unwrap().len(), 1); - - let output = NodeOutput::Paths(vec![path.clone(), path.clone()]); - assert!(output.as_paths().is_some()); - assert_eq!(output.as_paths().unwrap().len(), 2); - - let output = NodeOutput::Float(1.0); - assert!(output.as_paths().is_none()); - } - - #[test] - fn test_list_combine_single_items() { - // Test that list.combine works when each input is a single path - // This mimics the primitives.ndbx structure: colorize1 -> combine.list1, etc. - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::new(-100.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)), - ) - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(0.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)), - ) - .with_child( - Node::new("polygon1") - .with_prototype("corevector.polygon") - .with_input(Port::point("position", Point::new(100.0, 0.0))) - .with_input(Port::float("radius", 25.0)) - .with_input(Port::int("sides", 6)), - ) - .with_child( - Node::new("combine1") - .with_prototype("list.combine") - // Note: list.combine ports should accept lists, not iterate over them - .with_input(Port::geometry("list1").with_port_range(PortRange::List)) - .with_input(Port::geometry("list2").with_port_range(PortRange::List)) - .with_input(Port::geometry("list3").with_port_range(PortRange::List)), - ) - .with_connection(Connection::new("rect1", "combine1", "list1")) - .with_connection(Connection::new("ellipse1", "combine1", "list2")) - .with_connection(Connection::new("polygon1", "combine1", "list3")) - .with_rendered_child("combine1"); - - let paths = evaluate_network(&library); - - assert_eq!( - paths.len(), - 3, - "list.combine should produce 3 paths (one from each input), got {}", - paths.len() - ); - } - - #[test] - fn test_list_combine_with_colorize_chain() { - // Test the full primitives.ndbx structure: - // rect1 -> colorize1 -> combine1.list1 - // ellipse1 -> colorize2 -> combine1.list2 - // polygon1 -> colorize3 -> combine1.list3 - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::new(-100.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)), - ) - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(0.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)), - ) - .with_child( - Node::new("polygon1") - .with_prototype("corevector.polygon") - .with_input(Port::point("position", Point::new(100.0, 0.0))) - .with_input(Port::float("radius", 25.0)) - .with_input(Port::int("sides", 6)), - ) - .with_child( - Node::new("colorize1") - .with_prototype("corevector.colorize") - .with_input(Port::geometry("shape")) - .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))), - ) - .with_child( - Node::new("colorize2") - .with_prototype("corevector.colorize") - .with_input(Port::geometry("shape")) - .with_input(Port::color("fill", Color::rgb(0.0, 1.0, 0.0))), - ) - .with_child( - Node::new("colorize3") - .with_prototype("corevector.colorize") - .with_input(Port::geometry("shape")) - .with_input(Port::color("fill", Color::rgb(0.0, 0.0, 1.0))), - ) - .with_child( - Node::new("combine1") - .with_prototype("list.combine") - // NO port definitions - simulates ndbx file - ) - .with_connection(Connection::new("rect1", "colorize1", "shape")) - .with_connection(Connection::new("ellipse1", "colorize2", "shape")) - .with_connection(Connection::new("polygon1", "colorize3", "shape")) - .with_connection(Connection::new("colorize1", "combine1", "list1")) - .with_connection(Connection::new("colorize2", "combine1", "list2")) - .with_connection(Connection::new("colorize3", "combine1", "list3")) - .with_rendered_child("combine1"); - - let paths = evaluate_network(&library); - - assert_eq!( - paths.len(), - 3, - "combine1 should produce 3 colorized paths, got {}", - paths.len() - ); - - // Verify all paths have fills - for (i, path) in paths.iter().enumerate() { - assert!(path.fill.is_some(), "Path {} should have a fill color", i); - } - } - - #[test] - fn test_colorize_without_shape_port_defined() { - // Test colorize when the shape port is NOT defined (as in ndbx files) - // The ndbx file only defines ports that have non-default values - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)), - ) - .with_child( - Node::new("colorize1") - .with_prototype("corevector.colorize") - // Only fill is defined, NOT shape - mimics ndbx file - .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))), - ) - .with_connection(Connection::new("rect1", "colorize1", "shape")) - .with_rendered_child("colorize1"); - - let paths = evaluate_network(&library); - - assert_eq!( - paths.len(), - 1, - "colorize1 should produce 1 path even without shape port defined, got {}", - paths.len() - ); - assert!(paths[0].fill.is_some(), "Path should have a fill color"); - } - - #[test] - fn test_list_combine_without_port_range() { - // Test what happens when list.combine ports don't have PortRange::List set - // This is the case when loading from ndbx files that don't define ports - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::new(-100.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)), - ) - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::point("position", Point::new(0.0, 0.0))) - .with_input(Port::float("width", 50.0)) - .with_input(Port::float("height", 50.0)), - ) - .with_child( - Node::new("combine1") - .with_prototype("list.combine") - // NO port definitions - simulates ndbx file without explicit ports - ) - .with_connection(Connection::new("rect1", "combine1", "list1")) - .with_connection(Connection::new("ellipse1", "combine1", "list2")) - .with_rendered_child("combine1"); - - let paths = evaluate_network(&library); - - // With no port definitions, list matching treats inputs as VALUE range - // Each input is a single path, so iteration count = 1 - // list.combine should still combine them - assert_eq!( - paths.len(), - 2, - "list.combine should produce 2 paths even without port definitions, got {}", - paths.len() - ); - } - - #[test] - fn test_grid_to_rect_list_matching() { - // This test reproduces the bug: grid (100 points) -> rect should produce 100 rects - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("grid1") - .with_prototype("corevector.grid") - .with_input(Port::int("columns", 10)) - .with_input(Port::int("rows", 10)) - .with_input(Port::float("width", 300.0)) - .with_input(Port::float("height", 300.0)) - .with_input(Port::point("position", Point::ZERO)), - ) - .with_child( - Node::new("rect1") - .with_prototype("corevector.rect") - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 20.0)) - .with_input(Port::float("height", 20.0)) - .with_input(Port::point("roundness", Point::ZERO)), - ) - .with_connection(Connection::new("grid1", "rect1", "position")) - .with_rendered_child("rect1"); - - let paths = evaluate_network(&library); - - // THE KEY ASSERTION: Must produce 100 rectangles, not 1! - assert_eq!( - paths.len(), - 100, - "Grid (10x10=100 points) -> rect should produce 100 rectangles, got {}", - paths.len() - ); - } -} diff --git a/crates/nodebox-gui/src/history.rs b/crates/nodebox-gui/src/history.rs deleted file mode 100644 index 7bca3e57..00000000 --- a/crates/nodebox-gui/src/history.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! Undo/redo history management. - -use nodebox_core::node::NodeLibrary; - -/// Maximum number of undo states to keep. -const MAX_HISTORY: usize = 50; - -/// The undo/redo history manager. -pub struct History { - /// Past states (undo stack). - undo_stack: Vec, - /// Future states (redo stack). - redo_stack: Vec, - /// The last saved state (to track changes). - #[allow(dead_code)] - last_saved_state: Option, -} - -impl Default for History { - fn default() -> Self { - Self::new() - } -} - -impl History { - /// Create a new empty history. - pub fn new() -> Self { - Self { - undo_stack: Vec::new(), - redo_stack: Vec::new(), - last_saved_state: None, - } - } - - /// Check if undo is available. - pub fn can_undo(&self) -> bool { - !self.undo_stack.is_empty() - } - - /// Check if redo is available. - pub fn can_redo(&self) -> bool { - !self.redo_stack.is_empty() - } - - /// Save the current state before making changes. - /// Call this BEFORE modifying the library. - pub fn save_state(&mut self, library: &NodeLibrary) { - self.undo_stack.push(library.clone()); - - // Clear redo stack when new changes are made - self.redo_stack.clear(); - - // Limit history size - while self.undo_stack.len() > MAX_HISTORY { - self.undo_stack.remove(0); - } - } - - /// Undo the last change, returning the previous state. - /// Call this to restore the library to its previous state. - pub fn undo(&mut self, current: &NodeLibrary) -> Option { - if let Some(previous) = self.undo_stack.pop() { - // Save current state for redo - self.redo_stack.push(current.clone()); - Some(previous) - } else { - None - } - } - - /// Redo the last undone change, returning the restored state. - pub fn redo(&mut self, current: &NodeLibrary) -> Option { - if let Some(next) = self.redo_stack.pop() { - // Save current state for undo - self.undo_stack.push(current.clone()); - Some(next) - } else { - None - } - } - - /// Mark the current state as saved. - #[allow(dead_code)] - pub fn mark_saved(&mut self, library: &NodeLibrary) { - self.last_saved_state = Some(library.clone()); - } - - /// Check if the library has unsaved changes since the last save. - #[allow(dead_code)] - pub fn has_unsaved_changes(&self, library: &NodeLibrary) -> bool { - match &self.last_saved_state { - Some(saved) => saved != library, - None => true, // Never saved, so always has changes - } - } - - /// Clear all history. - #[allow(dead_code)] - pub fn clear(&mut self) { - self.undo_stack.clear(); - self.redo_stack.clear(); - } - - /// Get the number of undo states available. - pub fn undo_count(&self) -> usize { - self.undo_stack.len() - } - - /// Get the number of redo states available. - pub fn redo_count(&self) -> usize { - self.redo_stack.len() - } -} diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs deleted file mode 100644 index 0d378039..00000000 --- a/crates/nodebox-gui/src/node_library.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Node library browser for creating new nodes. -//! -//! Note: This module is work-in-progress and not yet integrated. - -#![allow(dead_code)] - -use eframe::egui; -use nodebox_core::geometry::{Color, Point}; -use nodebox_core::node::{Node, NodeLibrary, Port, PortRange, PortType}; - -/// Available node types that can be created. -pub struct NodeTemplate { - pub name: &'static str, - pub prototype: &'static str, - pub category: &'static str, - pub description: &'static str, -} - -/// List of all available node templates. -pub const NODE_TEMPLATES: &[NodeTemplate] = &[ - // Geometry generators - NodeTemplate { - name: "ellipse", - prototype: "corevector.ellipse", - category: "geometry", - description: "Create an ellipse or circle", - }, - NodeTemplate { - name: "rect", - prototype: "corevector.rect", - category: "geometry", - description: "Create a rectangle", - }, - NodeTemplate { - name: "line", - prototype: "corevector.line", - category: "geometry", - description: "Create a line between two points", - }, - NodeTemplate { - name: "polygon", - prototype: "corevector.polygon", - category: "geometry", - description: "Create a regular polygon", - }, - NodeTemplate { - name: "star", - prototype: "corevector.star", - category: "geometry", - description: "Create a star shape", - }, - NodeTemplate { - name: "arc", - prototype: "corevector.arc", - category: "geometry", - description: "Create an arc or pie slice", - }, - NodeTemplate { - name: "grid", - prototype: "corevector.grid", - category: "geometry", - description: "Create a grid of points", - }, - // Transform nodes - NodeTemplate { - name: "translate", - prototype: "corevector.translate", - category: "transform", - description: "Move geometry by offset", - }, - NodeTemplate { - name: "rotate", - prototype: "corevector.rotate", - category: "transform", - description: "Rotate geometry around a point", - }, - NodeTemplate { - name: "scale", - prototype: "corevector.scale", - category: "transform", - description: "Scale geometry", - }, - NodeTemplate { - name: "copy", - prototype: "corevector.copy", - category: "transform", - description: "Create multiple copies", - }, - // Color nodes - NodeTemplate { - name: "colorize", - prototype: "corevector.colorize", - category: "color", - description: "Set fill and stroke colors", - }, - // Combine nodes - NodeTemplate { - name: "merge", - prototype: "corevector.merge", - category: "geometry", - description: "Combine multiple shapes", - }, - NodeTemplate { - name: "group", - prototype: "corevector.group", - category: "geometry", - description: "Group shapes together", - }, - // Modify nodes - NodeTemplate { - name: "resample", - prototype: "corevector.resample", - category: "geometry", - description: "Resample path points", - }, - NodeTemplate { - name: "wiggle", - prototype: "corevector.wiggle", - category: "geometry", - description: "Add random displacement to points", - }, -]; - -/// The node library browser widget. -pub struct NodeLibraryBrowser { - search_text: String, - selected_category: Option, -} - -impl Default for NodeLibraryBrowser { - fn default() -> Self { - Self::new() - } -} - -impl NodeLibraryBrowser { - pub fn new() -> Self { - Self { - search_text: String::new(), - selected_category: None, - } - } - - /// Show the library browser and return the name of any node created. - pub fn show(&mut self, ui: &mut egui::Ui, library: &mut NodeLibrary) -> Option { - let mut created_node = None; - - // Search box - ui.horizontal(|ui| { - ui.label("Search:"); - ui.text_edit_singleline(&mut self.search_text); - }); - ui.add_space(5.0); - - // Category filter buttons - ui.horizontal_wrapped(|ui| { - let categories = ["geometry", "transform", "color"]; - for cat in categories { - let is_selected = self.selected_category.as_deref() == Some(cat); - if ui.selectable_label(is_selected, cat).clicked() { - if is_selected { - self.selected_category = None; - } else { - self.selected_category = Some(cat.to_string()); - } - } - } - if ui.selectable_label(self.selected_category.is_none() && self.search_text.is_empty(), "all").clicked() { - self.selected_category = None; - self.search_text.clear(); - } - }); - ui.separator(); - - // Node list - egui::ScrollArea::vertical().show(ui, |ui| { - for template in NODE_TEMPLATES { - // Filter by category - if let Some(ref cat) = self.selected_category { - if template.category != cat { - continue; - } - } - - // Filter by search text - if !self.search_text.is_empty() { - let search = self.search_text.to_lowercase(); - if !template.name.to_lowercase().contains(&search) - && !template.description.to_lowercase().contains(&search) - { - continue; - } - } - - // Display node button - ui.horizontal(|ui| { - if ui.button("+").clicked() { - // Calculate position (offset from last node or default) - let pos = if let Some(last_child) = library.root.children.last() { - Point::new(last_child.position.x + 180.0, last_child.position.y) - } else { - Point::new(50.0, 50.0) - }; - // Create the node - let node = create_node_from_template(template, library, pos); - let node_name = node.name.clone(); - library.root.children.push(node); - created_node = Some(node_name); - } - ui.label(template.name); - ui.label(format!("({})", template.category)).on_hover_text(template.description); - }); - } - }); - - created_node - } -} - -/// Create a new node from a template. -pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, position: Point) -> Node { - // Generate unique name - let base_name = template.name; - let name = library.root.unique_child_name(base_name); - - // Create node with appropriate ports based on prototype - let mut node = Node::new(&name) - .with_prototype(template.prototype) - .with_function(format!("corevector/{}", template.name)) - .with_category(template.category) - .with_position(position.x, position.y); - - // Add ports based on node type - match template.name { - "ellipse" => { - node = node - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)); - } - "rect" => { - node = node - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::point("roundness", Point::ZERO)); - } - "line" => { - node = node - .with_input(Port::point("point1", Point::ZERO)) - .with_input(Port::point("point2", Point::new(100.0, 100.0))) - .with_input(Port::int("points", 2)); - } - "polygon" => { - node = node - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("radius", 100.0)) - .with_input(Port::int("sides", 3)) - .with_input(Port::boolean("align", false)); - } - "star" => { - node = node - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::int("points", 20)) - .with_input(Port::float("outer", 200.0)) - .with_input(Port::float("inner", 100.0)); - } - "arc" => { - node = node - .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::float("start_angle", 0.0)) - .with_input(Port::float("degrees", 45.0)) - .with_input(Port::string("type", "pie")); - } - "grid" => { - node = node - .with_input(Port::int("columns", 10)) - .with_input(Port::int("rows", 10)) - .with_input(Port::float("width", 300.0)) - .with_input(Port::float("height", 300.0)) - .with_input(Port::point("position", Point::ZERO)) - .with_output_type(PortType::Point) - .with_output_range(PortRange::List); - } - "translate" => { - node = node - .with_input(Port::geometry("shape")) - .with_input(Port::point("translate", Point::ZERO)); - } - "rotate" => { - node = node - .with_input(Port::geometry("shape")) - .with_input(Port::float("angle", 0.0)) - .with_input(Port::point("origin", Point::ZERO)); - } - "scale" => { - node = node - .with_input(Port::geometry("shape")) - .with_input(Port::point("scale", Point::new(100.0, 100.0))) - .with_input(Port::point("origin", Point::ZERO)); - } - "copy" => { - node = node - .with_input(Port::geometry("shape")) - .with_input(Port::int("copies", 1)) - .with_input(Port::string("order", "tsr")) - .with_input(Port::point("translate", Point::ZERO)) - .with_input(Port::float("rotate", 0.0)) - .with_input(Port::point("scale", Point::new(100.0, 100.0))); - } - "colorize" => { - node = node - .with_input(Port::geometry("shape")) - .with_input(Port::color("fill", Color::rgb(0.5, 0.5, 0.5))) - .with_input(Port::color("stroke", Color::BLACK)) - .with_input(Port::float("strokeWidth", 1.0)); - } - "merge" | "group" => { - node = node.with_input(Port::geometry("shapes")); - } - "resample" => { - node = node - .with_input(Port::geometry("shape")) - .with_input(Port::string("method", "length")) - .with_input(Port::float("length", 10.0)) - .with_input(Port::int("points", 10)) - .with_input(Port::boolean("per_contour", false)); - } - "wiggle" => { - node = node - .with_input(Port::geometry("shape")) - .with_input(Port::string("scope", "points")) - .with_input(Port::point("offset", Point::new(10.0, 10.0))) - .with_input(Port::int("seed", 0)); - } - _ => {} - } - - node -} diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs deleted file mode 100644 index d4bc7dd8..00000000 --- a/crates/nodebox-gui/src/panels.rs +++ /dev/null @@ -1,687 +0,0 @@ -//! UI panels for the NodeBox application. - -use eframe::egui::{self, Sense, TextStyle}; -use nodebox_core::node::{PortType, Widget}; -use nodebox_core::Value; -use crate::components; -use crate::state::AppState; -use crate::theme; - -/// The parameter editor panel with Rerun-style minimal UI. -pub struct ParameterPanel { - /// Fixed width for labels. - label_width: f32, - /// Track which port is being edited (node_name, port_name, edit_text, needs_select_all) - editing: Option<(String, String, String, bool)>, -} - -impl Default for ParameterPanel { - fn default() -> Self { - Self::new() - } -} - -impl ParameterPanel { - /// Create a new parameter panel. - /// The label_width is set to theme::LABEL_WIDTH to align with the pane header separator. - pub fn new() -> Self { - Self { - label_width: theme::LABEL_WIDTH, - editing: None, - } - } - - /// Show the parameter panel. - pub fn show(&mut self, ui: &mut egui::Ui, state: &mut AppState) { - // Apply minimal styling for the panel - ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 2.0); - - if let Some(ref node_name) = state.selected_node.clone() { - // First, collect connected ports while we only have immutable borrow - let connected_ports: std::collections::HashSet = state - .library - .root - .connections - .iter() - .filter(|c| c.input_node == *node_name) - .map(|c| c.input_port.clone()) - .collect(); - - // Also collect display info before mutable borrow - let (node_display_name, node_prototype) = { - if let Some(node) = state.library.root.child(&node_name) { - (Some(node.name.clone()), node.prototype.clone()) - } else { - (None, None) - } - }; - - // Show header before mutable borrow - self.show_parameters_header( - ui, - node_display_name.as_deref(), - node_prototype.as_deref(), - ); - - // Find the node in the library for mutation - if let Some(node) = state.library.root.child_mut(&node_name) { - // Clone node_name for use in closure - let node_name_clone = node_name.clone(); - - // Show input ports in a scrollable area with two-tone background - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - // Paint two-tone background - let full_rect = ui.max_rect(); - // Left side (labels) - darker - ui.painter().rect_filled( - egui::Rect::from_min_max( - full_rect.min, - egui::pos2(full_rect.left() + self.label_width, full_rect.max.y), - ), - 0.0, - theme::PORT_LABEL_BACKGROUND, - ); - // Right side (values) - lighter - ui.painter().rect_filled( - egui::Rect::from_min_max( - egui::pos2(full_rect.left() + self.label_width, full_rect.min.y), - full_rect.max, - ), - 0.0, - theme::PORT_VALUE_BACKGROUND, - ); - - for port in &mut node.inputs { - let is_connected = connected_ports.contains(&port.name); - self.show_port_row(ui, port, is_connected, &node_name_clone); - } - }); - } else { - self.show_no_selection(ui, Some(&format!("Node '{}' not found.", node_name))); - } - } else { - // No node selected - show document properties - self.show_document_properties(ui, state); - } - } - - /// Show a single port row with label and value editor. - fn show_port_row( - &mut self, - ui: &mut egui::Ui, - port: &mut nodebox_core::node::Port, - is_connected: bool, - node_name: &str, - ) { - ui.horizontal(|ui| { - ui.set_height(theme::PARAMETER_ROW_HEIGHT); - - // Fixed-width label, right-aligned (non-selectable) - ui.allocate_ui_with_layout( - egui::Vec2::new(self.label_width, theme::PARAMETER_ROW_HEIGHT), - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.add_space(8.0); - // Use painter to draw text directly (non-selectable) - let galley = ui.painter().layout_no_wrap( - port.name.clone(), - egui::FontId::proportional(11.0), - theme::TEXT_NORMAL, - ); - let rect = ui.available_rect_before_wrap(); - let pos = egui::pos2( - rect.right() - galley.size().x - 8.0, - rect.center().y - galley.size().y / 2.0, - ); - ui.painter().galley(pos, galley, theme::TEXT_NORMAL); - }, - ); - - // Value editor - if is_connected { - // Non-selectable "connected" text - let galley = ui.painter().layout_no_wrap( - "connected".to_string(), - egui::FontId::proportional(11.0), - theme::TEXT_DISABLED, - ); - let rect = ui.available_rect_before_wrap(); - let pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); - ui.painter().galley(pos, galley, theme::TEXT_DISABLED); - } else { - self.show_port_editor(ui, port, node_name); - } - }); - } - - /// Show the editor widget for a port value - minimal style with no borders. - fn show_port_editor(&mut self, ui: &mut egui::Ui, port: &mut nodebox_core::node::Port, node_name: &str) { - let port_key = (node_name.to_string(), port.name.clone()); - - // Check if we're editing this port - let is_editing = self.editing.as_ref() - .map(|(n, p, _, _)| n == node_name && p == &port.name) - .unwrap_or(false); - - match port.widget { - Widget::Float | Widget::Angle => { - if let Value::Float(ref mut value) = port.value { - self.show_drag_value_float(ui, value, port.min, port.max, 1.0, &port_key, is_editing); - } - } - Widget::Int => { - if let Value::Int(ref mut value) = port.value { - self.show_drag_value_int(ui, value, &port_key, is_editing); - } - } - Widget::Toggle => { - if let Value::Boolean(ref mut value) = port.value { - // Non-selectable clickable boolean - let text = if *value { "true" } else { "false" }; - let galley = ui.painter().layout_no_wrap( - text.to_string(), - egui::FontId::proportional(11.0), - theme::VALUE_TEXT, - ); - let rect = ui.available_rect_before_wrap(); - let text_rect = egui::Rect::from_min_size( - egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0), - galley.size(), - ); - - let response = ui.allocate_rect(text_rect, Sense::click()); - ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); - - if response.clicked() { - *value = !*value; - } - if response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - } - } - Widget::String | Widget::Text => { - if let Value::String(ref mut value) = port.value { - if is_editing { - // Show text input - let (mut edit_text, needs_select) = self.editing.as_ref() - .map(|(_, _, t, sel)| (t.clone(), *sel)) - .unwrap_or_else(|| (value.clone(), true)); - - let output = egui::TextEdit::singleline(&mut edit_text) - .font(TextStyle::Body) - .text_color(theme::VALUE_TEXT) - .desired_width(120.0) - .frame(true) - .show(ui); - - // Select all on first frame - if needs_select { - if let Some((_, _, _, ref mut sel)) = self.editing { - *sel = false; - } - // Set cursor to select all - let text_len = edit_text.chars().count(); - let mut state = output.state.clone(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two( - egui::text::CCursor::new(0), - egui::text::CCursor::new(text_len), - ))); - state.store(ui.ctx(), output.response.id); - } - - // Update edit text - if let Some((_, _, ref mut t, _)) = self.editing { - *t = edit_text.clone(); - } - - // Commit on enter or focus lost - if output.response.lost_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - self.editing = None; - } else { - *value = edit_text; - self.editing = None; - } - } - - // Request focus on first frame - output.response.request_focus(); - } else { - // Show as clickable text - let display = if value.is_empty() { "\"\"" } else { value.as_str() }; - let galley = ui.painter().layout_no_wrap( - display.to_string(), - egui::FontId::proportional(11.0), - theme::VALUE_TEXT, - ); - let rect = ui.available_rect_before_wrap(); - let text_rect = egui::Rect::from_min_size( - egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0), - galley.size(), - ); - - let response = ui.allocate_rect(text_rect, Sense::click()); - ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); - - if response.clicked() { - self.editing = Some((port_key.0, port_key.1, value.clone(), true)); - } - if response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } - } - } - } - Widget::Color => { - if let Value::Color(ref mut color) = port.value { - let mut rgba = [ - (color.r * 255.0) as u8, - (color.g * 255.0) as u8, - (color.b * 255.0) as u8, - (color.a * 255.0) as u8, - ]; - if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() { - color.r = rgba[0] as f64 / 255.0; - color.g = rgba[1] as f64 / 255.0; - color.b = rgba[2] as f64 / 255.0; - color.a = rgba[3] as f64 / 255.0; - } - } - } - Widget::Point => { - if let Value::Point(ref mut point) = port.value { - let key_x = (port_key.0.clone(), format!("{}_x", port_key.1)); - let key_y = (port_key.0.clone(), format!("{}_y", port_key.1)); - let is_editing_x = self.editing.as_ref() - .map(|(n, p, _, _)| n == &key_x.0 && p == &key_x.1) - .unwrap_or(false); - let is_editing_y = self.editing.as_ref() - .map(|(n, p, _, _)| n == &key_y.0 && p == &key_y.1) - .unwrap_or(false); - self.show_drag_value_float(ui, &mut point.x, None, None, 1.0, &key_x, is_editing_x); - ui.add_space(4.0); - self.show_drag_value_float(ui, &mut point.y, None, None, 1.0, &key_y, is_editing_y); - } - } - _ => { - // For geometry and other non-editable types, show type info (non-selectable) - let type_str = match port.port_type { - PortType::Geometry => "Geometry", - _ => port.port_type.as_str(), - }; - let galley = ui.painter().layout_no_wrap( - type_str.to_string(), - egui::FontId::proportional(11.0), - theme::TEXT_DISABLED, - ); - let rect = ui.available_rect_before_wrap(); - let pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); - ui.painter().galley(pos, galley, theme::TEXT_DISABLED); - } - } - } - - /// Show a minimal drag value for floats - non-selectable, draggable, click to edit. - fn show_drag_value_float( - &mut self, - ui: &mut egui::Ui, - value: &mut f64, - min: Option, - max: Option, - speed: f64, - port_key: &(String, String), - is_editing: bool, - ) { - if is_editing { - // Show text input for direct editing - let (mut edit_text, needs_select) = self.editing.as_ref() - .map(|(_, _, t, sel)| (t.clone(), *sel)) - .unwrap_or_else(|| (format!("{:.2}", value), true)); - - let output = egui::TextEdit::singleline(&mut edit_text) - .font(TextStyle::Body) - .text_color(theme::VALUE_TEXT) - .desired_width(60.0) - .frame(true) - .show(ui); - - // Select all on first frame - if needs_select { - if let Some((_, _, _, ref mut sel)) = self.editing { - *sel = false; - } - let text_len = edit_text.chars().count(); - let mut state = output.state.clone(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two( - egui::text::CCursor::new(0), - egui::text::CCursor::new(text_len), - ))); - state.store(ui.ctx(), output.response.id); - } - - // Update edit text - if let Some((_, _, ref mut t, _)) = self.editing { - *t = edit_text.clone(); - } - - // Commit on enter or focus lost - if output.response.lost_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - self.editing = None; - } else if let Ok(new_val) = edit_text.parse::() { - let mut clamped = new_val; - if let Some(min_val) = min { - clamped = clamped.max(min_val); - } - if let Some(max_val) = max { - clamped = clamped.min(max_val); - } - *value = clamped; - self.editing = None; - } else { - self.editing = None; - } - } - - output.response.request_focus(); - } else { - // Show as draggable text (non-selectable) - let text = format!("{:.2}", value); - let galley = ui.painter().layout_no_wrap( - text.clone(), - egui::FontId::proportional(11.0), - theme::VALUE_TEXT, - ); - let rect = ui.available_rect_before_wrap(); - let text_rect = egui::Rect::from_min_size( - egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0), - galley.size(), - ); - - let response = ui.allocate_rect(text_rect, Sense::click_and_drag()); - ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); - - if response.dragged() { - // Modifier keys: Shift = x10, Alt = /100 - let modifier = ui.input(|i| { - if i.modifiers.shift { - 10.0 - } else if i.modifiers.alt { - 0.01 - } else { - 1.0 - } - }); - let delta = response.drag_delta().x as f64 * speed * modifier; - *value += delta; - if let Some(min_val) = min { - *value = value.max(min_val); - } - if let Some(max_val) = max { - *value = value.min(max_val); - } - } - - if response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } - - // Click to edit - if response.clicked() { - self.editing = Some((port_key.0.clone(), port_key.1.clone(), format!("{:.2}", value), true)); - } - } - } - - /// Show a minimal drag value for ints - non-selectable, draggable, click to edit. - fn show_drag_value_int(&mut self, ui: &mut egui::Ui, value: &mut i64, port_key: &(String, String), is_editing: bool) { - if is_editing { - // Show text input for direct editing - let (mut edit_text, needs_select) = self.editing.as_ref() - .map(|(_, _, t, sel)| (t.clone(), *sel)) - .unwrap_or_else(|| (format!("{}", value), true)); - - let output = egui::TextEdit::singleline(&mut edit_text) - .font(TextStyle::Body) - .text_color(theme::VALUE_TEXT) - .desired_width(60.0) - .frame(true) - .show(ui); - - // Select all on first frame - if needs_select { - if let Some((_, _, _, ref mut sel)) = self.editing { - *sel = false; - } - let text_len = edit_text.chars().count(); - let mut state = output.state.clone(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two( - egui::text::CCursor::new(0), - egui::text::CCursor::new(text_len), - ))); - state.store(ui.ctx(), output.response.id); - } - - if let Some((_, _, ref mut t, _)) = self.editing { - *t = edit_text.clone(); - } - - if output.response.lost_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - self.editing = None; - } else if let Ok(new_val) = edit_text.parse::() { - *value = new_val; - self.editing = None; - } else { - self.editing = None; - } - } - - output.response.request_focus(); - } else { - let text = format!("{}", value); - let galley = ui.painter().layout_no_wrap( - text.clone(), - egui::FontId::proportional(11.0), - theme::VALUE_TEXT, - ); - let rect = ui.available_rect_before_wrap(); - let text_rect = egui::Rect::from_min_size( - egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0), - galley.size(), - ); - - let response = ui.allocate_rect(text_rect, Sense::click_and_drag()); - ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); - - if response.dragged() { - // Modifier keys: Shift = x10, Alt = /100 - let modifier = ui.input(|i| { - if i.modifiers.shift { - 10.0 - } else if i.modifiers.alt { - 0.01 - } else { - 1.0 - } - }); - let delta = response.drag_delta().x as f64 * modifier; - *value += delta as i64; - } - - if response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } - - if response.clicked() { - self.editing = Some((port_key.0.clone(), port_key.1.clone(), format!("{}", value), true)); - } - } - } - - /// Show document properties when no node is selected. - fn show_no_selection(&mut self, ui: &mut egui::Ui, error: Option<&str>) { - if let Some(err) = error { - // Show header even for errors - self.show_parameters_header(ui, None, None); - ui.vertical_centered(|ui| { - ui.add_space(30.0); - ui.label( - egui::RichText::new(err) - .color(theme::ERROR_RED) - .size(12.0), - ); - }); - } else { - // Show merged header with "Document" - self.show_parameters_header(ui, Some("Document"), None); - - // Hint text - ui.vertical_centered(|ui| { - ui.add_space(theme::PADDING); - ui.label( - egui::RichText::new("Select a node to edit parameters") - .color(theme::TEXT_DISABLED) - .size(11.0), - ); - }); - } - } - - /// Show the merged parameters header: PARAMETERS | node_name ... prototype - fn show_parameters_header(&self, ui: &mut egui::Ui, node_name: Option<&str>, prototype: Option<&str>) { - let (header_rect, x) = components::draw_pane_header_with_title(ui, "Parameters"); - - // Only show node info if we have a node name - if let Some(name) = node_name { - // Node name after separator - ui.painter().text( - egui::pos2(x, header_rect.center().y), - egui::Align2::LEFT_CENTER, - name, - egui::FontId::proportional(10.0), - theme::TEXT_BRIGHT, - ); - - // Prototype on right - if let Some(proto) = prototype { - ui.painter().text( - header_rect.right_center() - egui::vec2(theme::PADDING, 0.0), - egui::Align2::RIGHT_CENTER, - proto, - egui::FontId::proportional(10.0), - theme::TEXT_DISABLED, - ); - } - } - } - - /// Show document properties panel (canvas size, etc.). - pub fn show_document_properties(&mut self, ui: &mut egui::Ui, state: &mut AppState) { - // Apply minimal styling for the panel - ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 2.0); - - // Merged header with "Document" - self.show_parameters_header(ui, Some("Document"), None); - - // Paint two-tone background for the content area - let content_rect = ui.available_rect_before_wrap(); - // Left side (labels) - darker - ui.painter().rect_filled( - egui::Rect::from_min_max( - content_rect.min, - egui::pos2(content_rect.left() + self.label_width, content_rect.max.y), - ), - 0.0, - theme::PORT_LABEL_BACKGROUND, - ); - // Right side (values) - lighter - ui.painter().rect_filled( - egui::Rect::from_min_max( - egui::pos2(content_rect.left() + self.label_width, content_rect.min.y), - content_rect.max, - ), - 0.0, - theme::PORT_VALUE_BACKGROUND, - ); - - // Width - ui.horizontal(|ui| { - ui.set_height(theme::PARAMETER_ROW_HEIGHT); - - // Label - ui.allocate_ui_with_layout( - egui::Vec2::new(self.label_width, theme::PARAMETER_ROW_HEIGHT), - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.add_space(8.0); - let galley = ui.painter().layout_no_wrap( - "width".to_string(), - egui::FontId::proportional(11.0), - theme::TEXT_NORMAL, - ); - let rect = ui.available_rect_before_wrap(); - let pos = egui::pos2( - rect.right() - galley.size().x - 8.0, - rect.center().y - galley.size().y / 2.0, - ); - ui.painter().galley(pos, galley, theme::TEXT_NORMAL); - }, - ); - - // Value - let mut width = state.library.width(); - let key = ("__document__".to_string(), "width".to_string()); - let is_editing = self.editing.as_ref() - .map(|(n, p, _, _)| n == &key.0 && p == &key.1) - .unwrap_or(false); - self.show_drag_value_float(ui, &mut width, Some(1.0), None, 1.0, &key, is_editing); - - // Update the property if changed - if (state.library.width() - width).abs() > 0.001 { - state.library.set_width(width); - } - }); - - // Height - ui.horizontal(|ui| { - ui.set_height(theme::PARAMETER_ROW_HEIGHT); - - // Label - ui.allocate_ui_with_layout( - egui::Vec2::new(self.label_width, theme::PARAMETER_ROW_HEIGHT), - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.add_space(8.0); - let galley = ui.painter().layout_no_wrap( - "height".to_string(), - egui::FontId::proportional(11.0), - theme::TEXT_NORMAL, - ); - let rect = ui.available_rect_before_wrap(); - let pos = egui::pos2( - rect.right() - galley.size().x - 8.0, - rect.center().y - galley.size().y / 2.0, - ); - ui.painter().galley(pos, galley, theme::TEXT_NORMAL); - }, - ); - - // Value - let mut height = state.library.height(); - let key = ("__document__".to_string(), "height".to_string()); - let is_editing = self.editing.as_ref() - .map(|(n, p, _, _)| n == &key.0 && p == &key.1) - .unwrap_or(false); - self.show_drag_value_float(ui, &mut height, Some(1.0), None, 1.0, &key, is_editing); - - // Update the property if changed - if (state.library.height() - height).abs() > 0.001 { - state.library.set_height(height); - } - }); - } -} diff --git a/crates/nodebox-gui/src/render_worker.rs b/crates/nodebox-gui/src/render_worker.rs deleted file mode 100644 index d058d0f9..00000000 --- a/crates/nodebox-gui/src/render_worker.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Background render worker for non-blocking network evaluation. - -use std::sync::mpsc; -use std::thread; -use nodebox_core::geometry::Path as GeoPath; -use nodebox_core::node::NodeLibrary; - -/// Unique identifier for a render request. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct RenderRequestId(u64); - -/// A request sent to the render worker. -pub enum RenderRequest { - /// Evaluate the network and return geometry. - Evaluate { id: RenderRequestId, library: NodeLibrary }, - /// Shut down the worker thread. - Shutdown, -} - -/// A result returned from the render worker. -#[allow(dead_code)] -pub enum RenderResult { - /// Evaluation succeeded. - Success { id: RenderRequestId, geometry: Vec }, - /// Evaluation failed. - Error { id: RenderRequestId, message: String }, -} - -/// Tracks the state of pending and completed renders. -pub struct RenderState { - next_id: u64, - latest_dispatched_id: Option, - /// Whether a render is currently in progress. - pub is_rendering: bool, -} - -impl RenderState { - /// Create a new render state. - pub fn new() -> Self { - Self { - next_id: 0, - latest_dispatched_id: None, - is_rendering: false, - } - } - - /// Dispatch a new render request and return its ID. - pub fn dispatch_new(&mut self) -> RenderRequestId { - let id = RenderRequestId(self.next_id); - self.next_id += 1; - self.latest_dispatched_id = Some(id); - self.is_rendering = true; - id - } - - /// Check if the given ID is the most recently dispatched. - pub fn is_current(&self, id: RenderRequestId) -> bool { - self.latest_dispatched_id == Some(id) - } - - /// Mark the current render as complete. - pub fn complete(&mut self) { - self.is_rendering = false; - } -} - -impl Default for RenderState { - fn default() -> Self { - Self::new() - } -} - -/// Handle to the background render worker thread. -pub struct RenderWorkerHandle { - request_tx: Option>, - result_rx: mpsc::Receiver, - thread_handle: Option>, -} - -impl RenderWorkerHandle { - /// Spawn a new render worker thread. - pub fn spawn() -> Self { - let (request_tx, request_rx) = mpsc::channel(); - let (result_tx, result_rx) = mpsc::channel(); - - let thread_handle = thread::spawn(move || { - render_worker_loop(request_rx, result_tx); - }); - - Self { - request_tx: Some(request_tx), - result_rx, - thread_handle: Some(thread_handle), - } - } - - /// Request a render of the given library. - pub fn request_render(&self, id: RenderRequestId, library: NodeLibrary) { - if let Some(ref tx) = self.request_tx { - let _ = tx.send(RenderRequest::Evaluate { id, library }); - } - } - - /// Try to receive a render result without blocking. - pub fn try_recv_result(&self) -> Option { - self.result_rx.try_recv().ok() - } - - /// Shut down the render worker thread. - pub fn shutdown(&mut self) { - // Send shutdown message - if let Some(tx) = self.request_tx.take() { - let _ = tx.send(RenderRequest::Shutdown); - } - // Wait for thread to finish - if let Some(handle) = self.thread_handle.take() { - let _ = handle.join(); - } - } -} - -impl Drop for RenderWorkerHandle { - fn drop(&mut self) { - self.shutdown(); - } -} - -/// The main loop of the render worker thread. -fn render_worker_loop( - request_rx: mpsc::Receiver, - result_tx: mpsc::Sender, -) { - loop { - match request_rx.recv() { - Ok(RenderRequest::Evaluate { id, library }) => { - // Drain to the latest request (skip stale ones) - let (final_id, final_library) = drain_to_latest(id, library, &request_rx); - - // Evaluate the network - let geometry = crate::eval::evaluate_network(&final_library); - let _ = result_tx.send(RenderResult::Success { - id: final_id, - geometry, - }); - } - Ok(RenderRequest::Shutdown) | Err(_) => break, - } - } -} - -/// Drain any pending requests and return the most recent one. -fn drain_to_latest( - mut id: RenderRequestId, - mut library: NodeLibrary, - rx: &mpsc::Receiver, -) -> (RenderRequestId, NodeLibrary) { - while let Ok(req) = rx.try_recv() { - match req { - RenderRequest::Evaluate { - id: new_id, - library: new_lib, - } => { - id = new_id; - library = new_lib; - } - RenderRequest::Shutdown => break, - } - } - (id, library) -} diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs deleted file mode 100644 index 925064ec..00000000 --- a/crates/nodebox-gui/src/state.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Application state management. - -use std::path::{Path, PathBuf}; -use nodebox_core::geometry::{Path as GeoPath, Color}; -use nodebox_core::node::{Node, NodeLibrary, Port, PortRange}; -use crate::eval; - -/// The main application state. -pub struct AppState { - /// Current file path (if saved). - pub current_file: Option, - - /// Whether the document has unsaved changes. - pub dirty: bool, - - /// Whether to show the about dialog. - pub show_about: bool, - - /// The current geometry to render. - pub geometry: Vec, - - /// Currently selected node (if any). - pub selected_node: Option, - - /// Canvas background color. - pub background_color: Color, - - /// The node library (document). - pub library: NodeLibrary, -} - -impl Default for AppState { - fn default() -> Self { - Self::new() - } -} - -impl AppState { - /// Create a new application state with demo content. - pub fn new() -> Self { - // Create a demo node library - let library = Self::create_demo_library(); - - // Evaluate the network to get the initial geometry - let geometry = eval::evaluate_network(&library); - - Self { - current_file: None, - dirty: false, - show_about: false, - geometry, - selected_node: None, - background_color: Color::WHITE, - library, - } - } - - /// Re-evaluate the network and update the geometry. - #[allow(dead_code)] - pub fn evaluate(&mut self) { - self.geometry = eval::evaluate_network(&self.library); - } - - /// Create a demo node library with a single rect node. - fn create_demo_library() -> NodeLibrary { - let mut library = NodeLibrary::new("demo"); - - let rect_node = Node::new("rect1") - .with_prototype("corevector.rect") - .with_function("corevector/rect") - .with_category("geometry") - .with_position(1.0, 1.0) - .with_input(Port::point("position", nodebox_core::geometry::Point::ZERO)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)) - .with_input(Port::point("roundness", nodebox_core::geometry::Point::ZERO)); - - library.root = Node::network("root") - .with_child(rect_node) - .with_rendered_child("rect1"); - - library - } - - /// Create a new empty document. - pub fn new_document(&mut self) { - self.current_file = None; - self.dirty = false; - self.geometry.clear(); - self.selected_node = None; - } - - /// Load a file. - pub fn load_file(&mut self, path: &Path) -> Result<(), String> { - // Parse the .ndbx file - let mut library = nodebox_ndbx::parse_file(path).map_err(|e| e.to_string())?; - - // Ensure all nodes have their default ports populated - populate_default_ports(&mut library.root); - - // Update state - self.library = library; - self.current_file = Some(path.to_path_buf()); - self.dirty = false; - self.selected_node = None; - - // Evaluate the network - self.geometry = eval::evaluate_network(&self.library); - - Ok(()) - } - - /// Save the current document. - pub fn save_file(&mut self, path: &Path) -> Result<(), String> { - // TODO: Implement proper .ndbx saving - self.current_file = Some(path.to_path_buf()); - self.dirty = false; - Ok(()) - } - - /// Export to SVG. - /// Uses document width/height and centered coordinate system. - pub fn export_svg(&self, path: &Path, width: f64, height: f64) -> Result<(), String> { - let options = nodebox_svg::SvgOptions::new(width, height) - .with_centered(true) - .with_background(Some(self.background_color)); - let svg = nodebox_svg::render_to_svg_with_options(&self.geometry, &options); - std::fs::write(path, svg).map_err(|e| e.to_string()) - } -} - -/// Populate default ports for nodes based on their prototype. -/// -/// When loading .ndbx files, only non-default port values are stored. -/// This function adds the missing default ports that nodes need for -/// connections to work properly. -pub fn populate_default_ports(node: &mut Node) { - // Recursively process children first - for child in &mut node.children { - populate_default_ports(child); - } - - // Add default ports based on prototype - if let Some(ref proto) = node.prototype { - match proto.as_str() { - // Geometry generators - port names match corevector.ndbx library - "corevector.ellipse" => { - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "width", || Port::float("width", 100.0)); - ensure_port(node, "height", || Port::float("height", 100.0)); - } - "corevector.rect" => { - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "width", || Port::float("width", 100.0)); - ensure_port(node, "height", || Port::float("height", 100.0)); - ensure_port(node, "roundness", || Port::point("roundness", nodebox_core::geometry::Point::ZERO)); - } - "corevector.line" => { - ensure_port(node, "point1", || Port::point("point1", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "point2", || Port::point("point2", nodebox_core::geometry::Point::new(100.0, 100.0))); - ensure_port(node, "points", || Port::int("points", 2)); - } - "corevector.polygon" => { - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "radius", || Port::float("radius", 100.0)); - ensure_port(node, "sides", || Port::int("sides", 3)); - ensure_port(node, "align", || Port::boolean("align", false)); - } - "corevector.star" => { - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "points", || Port::int("points", 20)); - ensure_port(node, "outer", || Port::float("outer", 200.0)); - ensure_port(node, "inner", || Port::float("inner", 100.0)); - } - "corevector.arc" => { - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "width", || Port::float("width", 100.0)); - ensure_port(node, "height", || Port::float("height", 100.0)); - ensure_port(node, "start_angle", || Port::float("start_angle", 0.0)); - ensure_port(node, "degrees", || Port::float("degrees", 45.0)); - ensure_port(node, "type", || Port::string("type", "pie")); - } - // Filters - "corevector.colorize" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "fill", || Port::color("fill", Color::WHITE)); - ensure_port(node, "stroke", || Port::color("stroke", Color::BLACK)); - ensure_port(node, "strokeWidth", || Port::float("strokeWidth", 1.0)); - } - "corevector.translate" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "translate", || Port::point("translate", nodebox_core::geometry::Point::ZERO)); - } - "corevector.rotate" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "angle", || Port::float("angle", 0.0)); - ensure_port(node, "origin", || Port::point("origin", nodebox_core::geometry::Point::ZERO)); - } - "corevector.scale" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "scale", || Port::point("scale", nodebox_core::geometry::Point::new(100.0, 100.0))); - ensure_port(node, "origin", || Port::point("origin", nodebox_core::geometry::Point::ZERO)); - } - "corevector.copy" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "copies", || Port::int("copies", 1)); - ensure_port(node, "order", || Port::string("order", "tsr")); - ensure_port(node, "translate", || Port::point("translate", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "rotate", || Port::float("rotate", 0.0)); - ensure_port(node, "scale", || Port::point("scale", nodebox_core::geometry::Point::new(100.0, 100.0))); - } - "corevector.align" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "halign", || Port::string("halign", "center")); - ensure_port(node, "valign", || Port::string("valign", "middle")); - } - "corevector.fit" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - ensure_port(node, "width", || Port::float("width", 300.0)); - ensure_port(node, "height", || Port::float("height", 300.0)); - ensure_port(node, "keep_proportions", || Port::boolean("keep_proportions", true)); - } - "corevector.resample" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "points", || Port::int("points", 10)); - } - "corevector.wiggle" => { - ensure_port(node, "shape", || Port::geometry("shape")); - ensure_port(node, "scope", || Port::string("scope", "points")); - ensure_port(node, "offset", || Port::point("offset", nodebox_core::geometry::Point::new(10.0, 10.0))); - ensure_port(node, "seed", || Port::int("seed", 0)); - } - // Combine operations - "corevector.merge" | "corevector.combine" => { - // shapes port expects a list of shapes, not individual values to iterate over - ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); - } - "corevector.group" => { - ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); - } - "corevector.stack" => { - ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); - ensure_port(node, "direction", || Port::string("direction", "east")); - ensure_port(node, "margin", || Port::float("margin", 0.0)); - } - "corevector.sort" => { - ensure_port(node, "shapes", || Port::geometry("shapes").with_port_range(PortRange::List)); - ensure_port(node, "order_by", || Port::string("order_by", "x")); - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - } - "list.combine" => { - // list.combine ports should be LIST-range so empty inputs don't block evaluation - ensure_port(node, "list1", || Port::geometry("list1").with_port_range(PortRange::List)); - ensure_port(node, "list2", || Port::geometry("list2").with_port_range(PortRange::List)); - ensure_port(node, "list3", || Port::geometry("list3").with_port_range(PortRange::List)); - ensure_port(node, "list4", || Port::geometry("list4").with_port_range(PortRange::List)); - ensure_port(node, "list5", || Port::geometry("list5").with_port_range(PortRange::List)); - } - // Grid - "corevector.grid" => { - ensure_port(node, "columns", || Port::int("columns", 10)); - ensure_port(node, "rows", || Port::int("rows", 10)); - ensure_port(node, "width", || Port::float("width", 300.0)); - ensure_port(node, "height", || Port::float("height", 300.0)); - ensure_port(node, "position", || Port::point("position", nodebox_core::geometry::Point::ZERO)); - } - // Connect - "corevector.connect" => { - // points port expects a list of points, not individual values to iterate over - ensure_port(node, "points", || Port::geometry("points").with_port_range(PortRange::List)); - ensure_port(node, "closed", || Port::boolean("closed", false)); - } - // Point - "corevector.point" | "corevector.makePoint" => { - ensure_port(node, "x", || Port::float("x", 0.0)); - ensure_port(node, "y", || Port::float("y", 0.0)); - } - _ => {} - } - } -} - -/// Ensure a port exists on a node, adding it with the default if missing. -fn ensure_port(node: &mut Node, name: &str, default: F) -where - F: FnOnce() -> Port, -{ - if node.input(name).is_none() { - node.inputs.push(default()); - } -} - diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs deleted file mode 100644 index ce2770ce..00000000 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ /dev/null @@ -1,1087 +0,0 @@ -//! Tabbed viewer pane with canvas and data views. - -use eframe::egui::{self, Color32, ColorImage, Pos2, Rect, Stroke, TextureHandle, TextureOptions, Vec2}; -use nodebox_core::geometry::{Color, Path, Point, PointType}; -use crate::components; -use crate::handles::{FourPointHandle, HandleSet, HANDLE_COLOR}; -use crate::pan_zoom::PanZoom; -use crate::state::AppState; -use crate::theme; - -#[cfg(feature = "gpu-rendering")] -use crate::vello_viewer::VelloViewer; -#[cfg(feature = "gpu-rendering")] -use std::hash::{Hash, Hasher}; - -/// Re-export or define RenderState type for unified API. -/// When gpu-rendering is enabled, this is egui_wgpu::RenderState. -/// When disabled, we use a unit type placeholder. -#[cfg(feature = "gpu-rendering")] -pub type RenderState = egui_wgpu::RenderState; - -#[cfg(not(feature = "gpu-rendering"))] -pub type RenderState = (); - -/// Result of handle interaction. -#[derive(Clone, Debug)] -pub enum HandleResult { - /// No interaction occurred. - None, - /// A single point changed (for regular handles). - PointChange { param: String, value: Point }, - /// FourPointHandle changed (x, y, width, height). - FourPointChange { x: f64, y: f64, width: f64, height: f64 }, -} - -/// Which tab is currently selected in the viewer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ViewerTab { - Viewer, - Data, -} - -/// Cached textures for outlined digit rendering (Houdini-style). -struct DigitCache { - /// Texture handles for digits 0-9. - textures: [Option; 10], - /// Width of each digit texture. - digit_width: f32, - /// Height of each digit texture. - digit_height: f32, -} - -impl DigitCache { - fn new() -> Self { - Self { - textures: Default::default(), - digit_width: 0.0, - digit_height: 0.0, - } - } - - /// Ensure digit textures are created. - fn ensure_initialized(&mut self, ctx: &egui::Context) { - if self.textures[0].is_some() { - return; // Already initialized - } - - // Create outlined digit textures - const FONT_SIZE: f32 = 12.0; - const PADDING: usize = 2; // For outline - - for digit in 0..10 { - let digit_char = char::from_digit(digit as u32, 10).unwrap(); - let image = Self::render_outlined_digit(ctx, digit_char, FONT_SIZE, PADDING); - - if digit == 0 { - self.digit_width = image.width() as f32; - self.digit_height = image.height() as f32; - } - - let texture = ctx.load_texture( - format!("digit_{}", digit), - image, - TextureOptions::LINEAR, - ); - self.textures[digit] = Some(texture); - } - } - - /// Render a single digit with white outline and blue fill. - fn render_outlined_digit(ctx: &egui::Context, digit: char, font_size: f32, padding: usize) -> ColorImage { - // Use egui's font system to get glyph info - let font_id = egui::FontId::proportional(font_size); - - // Get the galley for measuring - let galley = ctx.fonts_mut(|f| { - f.layout_no_wrap(digit.to_string(), font_id.clone(), Color32::WHITE) - }); - - let glyph_width = galley.rect.width().ceil() as usize; - let glyph_height = galley.rect.height().ceil() as usize; - - // Image size with padding for outline - let width = glyph_width + padding * 2 + 2; - let height = glyph_height + padding * 2; - - // Create image buffer - let mut pixels = vec![Color32::TRANSPARENT; width * height]; - - // Render outline (white) by sampling at offsets - let outline_color = Color32::WHITE; - let fill_color = HANDLE_COLOR; - - // Get font texture and UV info for the glyph - // Since we can't easily access raw glyph data, use a simpler approach: - // Render using a pre-defined bitmap font pattern for digits - let bitmap = get_digit_bitmap(digit); - - let scale = (font_size / 8.0).max(1.0) as usize; // Scale factor - let bmp_width = 5 * scale; - let bmp_height = 7 * scale; - - // Center the bitmap in the image - let offset_x = (width - bmp_width) / 2; - let offset_y = (height - bmp_height) / 2; - - // Draw outline first (white, offset in 8 directions) - for dy in -1i32..=1 { - for dx in -1i32..=1 { - if dx == 0 && dy == 0 { - continue; - } - draw_digit_bitmap(&mut pixels, width, &bitmap, scale, - (offset_x as i32 + dx) as usize, - (offset_y as i32 + dy) as usize, - outline_color); - } - } - - // Draw fill (blue) - draw_digit_bitmap(&mut pixels, width, &bitmap, scale, offset_x, offset_y, fill_color); - - ColorImage { - size: [width, height], - pixels, - source_size: egui::Vec2::new(width as f32, height as f32), - } - } - - /// Get texture for a digit. - fn get(&self, digit: usize) -> Option<&TextureHandle> { - self.textures.get(digit).and_then(|t| t.as_ref()) - } -} - -/// 5x7 bitmap font for digits 0-9. -fn get_digit_bitmap(digit: char) -> [u8; 7] { - match digit { - '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], - '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], - '2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111], - '3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110], - '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], - '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], - '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], - '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], - '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], - '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], - _ => [0; 7], - } -} - -/// Draw a digit bitmap to the pixel buffer. -fn draw_digit_bitmap(pixels: &mut [Color32], img_width: usize, bitmap: &[u8; 7], scale: usize, x_off: usize, y_off: usize, color: Color32) { - for (row, bits) in bitmap.iter().enumerate() { - for col in 0..5 { - if (bits >> (4 - col)) & 1 == 1 { - // Draw scaled pixel - for sy in 0..scale { - for sx in 0..scale { - let px = x_off + col * scale + sx; - let py = y_off + row * scale + sy; - if px < img_width && py < pixels.len() / img_width { - pixels[py * img_width + px] = color; - } - } - } - } - } - } -} - -/// The tabbed viewer pane. -pub struct ViewerPane { - /// Currently selected tab. - current_tab: ViewerTab, - /// Whether to show handles. - pub show_handles: bool, - /// Whether to show points. - pub show_points: bool, - /// Whether to show point numbers. - pub show_point_numbers: bool, - /// Whether to show origin crosshair. - pub show_origin: bool, - /// Whether to show the canvas border. - pub show_canvas_border: bool, - /// Pan and zoom state. - pan_zoom: PanZoom, - /// Active handles for the selected node. - handles: Option, - /// FourPointHandle for rect nodes. - four_point_handle: Option, - /// Index of handle being dragged. - dragging_handle: Option, - /// Whether space bar is currently pressed (for panning). - is_space_pressed: bool, - /// Whether we are currently panning with space+drag. - is_panning: bool, - /// Cached digit textures for point numbers. - digit_cache: DigitCache, - /// GPU-accelerated Vello viewer (when gpu-rendering feature is enabled). - #[cfg(feature = "gpu-rendering")] - vello_viewer: VelloViewer, - /// Whether to use GPU rendering (can be toggled at runtime). - #[cfg(feature = "gpu-rendering")] - pub use_gpu_rendering: bool, -} - -impl Default for ViewerPane { - fn default() -> Self { - Self::new() - } -} - -impl ViewerPane { - /// Create a new viewer pane. - pub fn new() -> Self { - Self { - current_tab: ViewerTab::Viewer, - show_handles: true, - show_points: false, - show_point_numbers: false, - show_origin: true, - show_canvas_border: true, - pan_zoom: PanZoom::with_zoom_limits(0.1, 10.0), - handles: None, - four_point_handle: None, - dragging_handle: None, - is_space_pressed: false, - is_panning: false, - digit_cache: DigitCache::new(), - #[cfg(feature = "gpu-rendering")] - vello_viewer: VelloViewer::new(), - #[cfg(feature = "gpu-rendering")] - use_gpu_rendering: true, // Default to GPU rendering when available - } - } - - /// Get the current zoom level. - pub fn zoom(&self) -> f32 { - self.pan_zoom.zoom - } - - /// Get the current pan offset. - #[allow(dead_code)] - pub fn pan(&self) -> Vec2 { - self.pan_zoom.pan - } - - /// Zoom in by a step. - #[allow(dead_code)] - pub fn zoom_in(&mut self) { - self.pan_zoom.zoom_in(); - } - - /// Zoom out by a step. - #[allow(dead_code)] - pub fn zoom_out(&mut self) { - self.pan_zoom.zoom_out(); - } - - /// Fit the view to show all geometry. - #[allow(dead_code)] - pub fn fit_to_window(&mut self) { - self.pan_zoom.reset(); - } - - /// Reset zoom to 100% (actual size). - pub fn reset_zoom(&mut self) { - self.pan_zoom.reset(); - } - - /// Compute a hash of the geometry for cache invalidation. - #[cfg(feature = "gpu-rendering")] - fn hash_geometry(geometry: &[Path]) -> u64 { - use std::collections::hash_map::DefaultHasher; - let mut hasher = DefaultHasher::new(); - // Hash path count and basic properties - geometry.len().hash(&mut hasher); - for path in geometry { - path.contours.len().hash(&mut hasher); - for contour in &path.contours { - contour.points.len().hash(&mut hasher); - contour.closed.hash(&mut hasher); - // Hash actual point coordinates (critical for cache invalidation!) - for point in &contour.points { - point.point.x.to_bits().hash(&mut hasher); - point.point.y.to_bits().hash(&mut hasher); - std::mem::discriminant(&point.point_type).hash(&mut hasher); - } - } - // Hash fill color - if let Some(fill) = path.fill { - fill.r.to_bits().hash(&mut hasher); - fill.g.to_bits().hash(&mut hasher); - fill.b.to_bits().hash(&mut hasher); - fill.a.to_bits().hash(&mut hasher); - } - // Hash stroke color - if let Some(stroke) = path.stroke { - stroke.r.to_bits().hash(&mut hasher); - stroke.g.to_bits().hash(&mut hasher); - stroke.b.to_bits().hash(&mut hasher); - stroke.a.to_bits().hash(&mut hasher); - } - // Hash stroke width - path.stroke_width.to_bits().hash(&mut hasher); - } - hasher.finish() - } - - /// Get a mutable reference to the handles. - #[allow(dead_code)] - pub fn handles_mut(&mut self) -> &mut Option { - &mut self.handles - } - - /// Set handles. - #[allow(dead_code)] - pub fn set_handles(&mut self, handles: Option) { - self.handles = handles; - } - - /// Show the viewer pane with header tabs and toolbar. - /// Returns any handle interaction result. - /// - /// Pass `render_state` for GPU-accelerated rendering when available. - /// When `gpu-rendering` feature is disabled, pass `None`. - pub fn show(&mut self, ui: &mut egui::Ui, state: &AppState, render_state: Option<&RenderState>) -> HandleResult { - // Remove spacing so content is snug against header - ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); - - // Draw header with "VIEWER" title and separator - let (header_rect, mut x) = components::draw_pane_header_with_title(ui, "Viewer"); - - // Segmented control for Visual/Data toggle - let selected_index = if self.current_tab == ViewerTab::Viewer { 0 } else { 1 }; - let (clicked_index, new_x) = components::header_segmented_control( - ui, - header_rect, - x, - ["Visual", "Data"], - selected_index, - ); - if let Some(index) = clicked_index { - self.current_tab = if index == 0 { ViewerTab::Viewer } else { ViewerTab::Data }; - } - x = new_x + theme::PADDING_XL; // 16px spacing after segmented control - - let (clicked, new_x) = components::header_tab_button( - ui, - header_rect, - x, - "Handles", - self.current_tab == ViewerTab::Viewer && self.show_handles, - ); - if clicked && self.current_tab == ViewerTab::Viewer { - self.show_handles = !self.show_handles; - } else if clicked { - self.current_tab = ViewerTab::Viewer; - } - x = new_x; - - let (clicked, new_x) = components::header_tab_button( - ui, - header_rect, - x, - "Points", - self.current_tab == ViewerTab::Viewer && self.show_points, - ); - if clicked && self.current_tab == ViewerTab::Viewer { - self.show_points = !self.show_points; - } else if clicked { - self.current_tab = ViewerTab::Viewer; - } - x = new_x; - - let (clicked, new_x) = components::header_tab_button( - ui, - header_rect, - x, - "Pt#", - self.current_tab == ViewerTab::Viewer && self.show_point_numbers, - ); - if clicked && self.current_tab == ViewerTab::Viewer { - self.show_point_numbers = !self.show_point_numbers; - } else if clicked { - self.current_tab = ViewerTab::Viewer; - } - x = new_x; - - let (clicked, new_x) = components::header_tab_button( - ui, - header_rect, - x, - "Origin", - self.current_tab == ViewerTab::Viewer && self.show_origin, - ); - if clicked && self.current_tab == ViewerTab::Viewer { - self.show_origin = !self.show_origin; - } else if clicked { - self.current_tab = ViewerTab::Viewer; - } - x = new_x; - - let (clicked, _) = components::header_tab_button( - ui, - header_rect, - x, - "Canvas", - self.current_tab == ViewerTab::Viewer && self.show_canvas_border, - ); - if clicked && self.current_tab == ViewerTab::Viewer { - self.show_canvas_border = !self.show_canvas_border; - } else if clicked { - self.current_tab = ViewerTab::Viewer; - } - - // Content area (directly after header, no extra spacing) - match self.current_tab { - ViewerTab::Viewer => self.show_canvas(ui, state, render_state), - ViewerTab::Data => { - self.show_data_view(ui, state); - HandleResult::None - } - } - } - - /// Show the canvas viewer. - /// Uses GPU rendering when available (gpu-rendering feature + valid render_state + use_gpu_rendering enabled). - /// Falls back to CPU rendering otherwise. - fn show_canvas(&mut self, ui: &mut egui::Ui, state: &AppState, render_state: Option<&RenderState>) -> HandleResult { - use crate::handles::{screen_to_world, FourPointDragState}; - - // Initialize digit cache if needed - self.digit_cache.ensure_initialized(ui.ctx()); - - let (response, painter) = - ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); - - let rect = response.rect; - let center = rect.center().to_vec2(); - - // Handle zoom with scroll wheel, centered on mouse position - self.pan_zoom.handle_scroll_zoom(rect, ui, center); - - // Track space bar state for Photoshop-style panning - if ui.input(|i| i.key_pressed(egui::Key::Space)) { - self.is_space_pressed = true; - } - if ui.input(|i| i.key_released(egui::Key::Space)) { - self.is_space_pressed = false; - self.is_panning = false; - } - - // Handle panning with space+drag, middle mouse button, or right drag - let is_panning = self.is_space_pressed && response.dragged_by(egui::PointerButton::Primary); - if is_panning { - self.pan_zoom.pan += response.drag_delta(); - self.is_panning = true; - } - self.pan_zoom.handle_drag_pan(&response, egui::PointerButton::Middle); - self.pan_zoom.handle_drag_pan(&response, egui::PointerButton::Secondary); - - // Change cursor when space is held (panning mode) - if self.is_space_pressed && response.hovered() { - if self.is_panning { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - } - - // Draw background - let bg_color = egui::Color32::from_rgb( - (state.background_color.r * 255.0) as u8, - (state.background_color.g * 255.0) as u8, - (state.background_color.b * 255.0) as u8, - ); - painter.rect_filled(rect, 0.0, bg_color); - - // Draw a subtle grid - self.draw_grid(&painter, rect); - - // Draw canvas border (uses document width/height) - if self.show_canvas_border { - self.draw_canvas_border(&painter, center, state.library.width(), state.library.height()); - } - - // Draw all geometry (using GPU or CPU rendering) - self.render_geometry(ui, &painter, state, render_state, rect, center); - - // Draw origin crosshair - if self.show_origin { - let origin = self.pan_zoom.world_to_screen(Pos2::ZERO, center); - if rect.contains(origin) { - let crosshair_size = 10.0; - painter.line_segment( - [ - origin - Vec2::new(crosshair_size, 0.0), - origin + Vec2::new(crosshair_size, 0.0), - ], - Stroke::new(1.0, theme::VIEWER_CROSSHAIR), - ); - painter.line_segment( - [ - origin - Vec2::new(0.0, crosshair_size), - origin + Vec2::new(0.0, crosshair_size), - ], - Stroke::new(1.0, theme::VIEWER_CROSSHAIR), - ); - } - } - - // Draw and handle interactive handles - if self.show_handles { - if let Some(ref handles) = self.handles { - handles.draw(&painter, self.pan_zoom.zoom, self.pan_zoom.pan, center); - } - if let Some(ref handle) = self.four_point_handle { - handle.draw(&painter, self.pan_zoom.zoom, self.pan_zoom.pan, center); - } - } - - // Draw point numbers on top of everything (including handles) - if self.show_point_numbers { - for path in &state.geometry { - self.draw_point_numbers(&painter, path, center); - } - } - - // Handle interactions (only if not panning) - if !self.is_space_pressed && self.show_handles { - let mouse_pos = ui.input(|i| i.pointer.hover_pos()); - - // Handle FourPointHandle first (takes priority) - if let Some(ref mut four_point) = self.four_point_handle { - // Check for drag start - if response.drag_started_by(egui::PointerButton::Primary) { - if let Some(pos) = mouse_pos { - if let Some(hit_state) = four_point.hit_test(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center) { - let world_pos = screen_to_world(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); - four_point.start_drag(hit_state, world_pos); - } - } - } - - // Handle dragging - if four_point.is_dragging() { - if response.drag_stopped_by(egui::PointerButton::Primary) { - // Drag ended - return final values - let (x, y, width, height) = four_point.end_drag(); - return HandleResult::FourPointChange { x, y, width, height }; - } else if response.dragged_by(egui::PointerButton::Primary) { - // Still dragging - update and return current values for live preview - if let Some(pos) = mouse_pos { - let world_pos = screen_to_world(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); - four_point.update_drag(world_pos); - } - // Return current values to trigger re-render - return HandleResult::FourPointChange { - x: four_point.center.x, - y: four_point.center.y, - width: four_point.width, - height: four_point.height, - }; - } - } - - // If FourPointHandle is dragging, don't process regular handles - if four_point.drag_state != FourPointDragState::None { - return HandleResult::None; - } - } - - // Check for regular handle dragging - if let Some(ref mut handles) = self.handles { - // Check for drag start - if response.drag_started_by(egui::PointerButton::Primary) { - if let Some(pos) = mouse_pos { - if let Some(idx) = handles.hit_test(pos, self.pan_zoom.zoom, self.pan_zoom.pan, center) { - self.dragging_handle = Some(idx); - if let Some(handle) = handles.handles_mut().get_mut(idx) { - handle.dragging = true; - } - } - } - } - - // Handle dragging - if let Some(idx) = self.dragging_handle { - if response.drag_stopped_by(egui::PointerButton::Primary) { - // Drag ended - if let Some(handle) = handles.handles_mut().get_mut(idx) { - handle.dragging = false; - let param_name = handle.param_name.clone(); - let position = handle.position; - self.dragging_handle = None; - return HandleResult::PointChange { param: param_name, value: position }; - } - self.dragging_handle = None; - } else if response.dragged_by(egui::PointerButton::Primary) { - // Still dragging - update and return current values for live preview - if let Some(pos) = mouse_pos { - handles.update_handle_position(idx, pos, self.pan_zoom.zoom, self.pan_zoom.pan, center); - } - // Return current values to trigger re-render - if let Some(handle) = handles.handles().get(idx) { - return HandleResult::PointChange { - param: handle.param_name.clone(), - value: handle.position, - }; - } - } - } - } - } - - HandleResult::None - } - - /// Render geometry using GPU when available, falling back to CPU. - #[cfg(feature = "gpu-rendering")] - fn render_geometry( - &mut self, - ui: &mut egui::Ui, - painter: &egui::Painter, - state: &AppState, - render_state: Option<&RenderState>, - rect: Rect, - center: Vec2, - ) { - let use_gpu = render_state.is_some() - && self.use_gpu_rendering - && self.vello_viewer.is_available(); - - if use_gpu { - let render_state = render_state.unwrap(); - - // Compute geometry hash for cache invalidation - let geometry_hash = Self::hash_geometry(&state.geometry); - - // Set background color (Vello will render the background) - self.vello_viewer.set_background_color(state.background_color); - - // Render with Vello using shared wgpu device - self.vello_viewer.render( - render_state, - ui, - &state.geometry, - self.pan_zoom.pan, - self.pan_zoom.zoom, - rect, - geometry_hash, - ); - - // Draw points overlay if enabled (still use egui for this) - if self.show_points { - for path in &state.geometry { - self.draw_points(painter, path, center); - } - } - } else { - self.render_geometry_cpu(painter, state, center); - } - } - - /// Render geometry using CPU (when gpu-rendering feature is disabled). - #[cfg(not(feature = "gpu-rendering"))] - fn render_geometry( - &mut self, - _ui: &mut egui::Ui, - painter: &egui::Painter, - state: &AppState, - _render_state: Option<&RenderState>, - _rect: Rect, - center: Vec2, - ) { - self.render_geometry_cpu(painter, state, center); - } - - /// CPU-based geometry rendering (used as fallback or when GPU is unavailable). - fn render_geometry_cpu(&self, painter: &egui::Painter, state: &AppState, center: Vec2) { - for path in &state.geometry { - self.draw_path(painter, path, center); - - if self.show_points { - self.draw_points(painter, path, center); - } - } - } - - /// Show the data view (placeholder for now). - fn show_data_view(&mut self, ui: &mut egui::Ui, state: &AppState) { - ui.vertical_centered(|ui| { - ui.add_space(50.0); - ui.label( - egui::RichText::new("Data View") - .color(theme::TEXT_DISABLED) - .size(16.0), - ); - ui.add_space(10.0); - ui.label( - egui::RichText::new("Tabular view of geometry data coming soon.") - .color(theme::TEXT_DISABLED) - .size(12.0), - ); - ui.add_space(20.0); - - // Show some basic stats - ui.label( - egui::RichText::new(format!("Paths: {}", state.geometry.len())) - .color(theme::TEXT_NORMAL) - .size(12.0), - ); - - let total_points: usize = state - .geometry - .iter() - .flat_map(|p| &p.contours) - .map(|c| c.points.len()) - .sum(); - ui.label( - egui::RichText::new(format!("Total points: {}", total_points)) - .color(theme::TEXT_NORMAL) - .size(12.0), - ); - }); - } - - /// Draw the canvas border (document bounds). - /// The border is drawn in screen space (constant 1px line width regardless of zoom). - fn draw_canvas_border(&self, painter: &egui::Painter, center: Vec2, width: f64, height: f64) { - // Canvas is centered at origin, so bounds are from -width/2 to +width/2 - let half_width = width as f32 / 2.0; - let half_height = height as f32 / 2.0; - - let top_left = Pos2::new(-half_width, -half_height); - let bottom_right = Pos2::new(half_width, half_height); - - let screen_top_left = self.pan_zoom.world_to_screen(top_left, center); - let screen_bottom_right = self.pan_zoom.world_to_screen(bottom_right, center); - - let canvas_rect = Rect::from_min_max(screen_top_left, screen_bottom_right); - - // Draw border with constant 1px line width (screen space) - let border_color = Color32::from_rgba_unmultiplied(128, 128, 128, 180); - painter.rect_stroke(canvas_rect, 0.0, Stroke::new(1.0, border_color), egui::StrokeKind::Inside); - } - - /// Draw a background grid. - fn draw_grid(&self, painter: &egui::Painter, rect: Rect) { - let grid_size = 50.0 * self.pan_zoom.zoom; - let grid_color = theme::viewer_grid(); - - let center = rect.center().to_vec2(); - let origin = self.pan_zoom.pan + center; - - // Calculate grid offset - let offset_x = origin.x % grid_size; - let offset_y = origin.y % grid_size; - - // Vertical lines - let mut x = rect.left() + offset_x; - while x < rect.right() { - painter.line_segment( - [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], - Stroke::new(1.0, grid_color), - ); - x += grid_size; - } - - // Horizontal lines - let mut y = rect.top() + offset_y; - while y < rect.bottom() { - painter.line_segment( - [Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)], - Stroke::new(1.0, grid_color), - ); - y += grid_size; - } - } - - /// Draw path points. - fn draw_points(&self, painter: &egui::Painter, path: &Path, center: Vec2) { - for contour in &path.contours { - for pp in contour.points.iter() { - let world_pt = Pos2::new(pp.point.x as f32, pp.point.y as f32); - let screen_pt = self.pan_zoom.world_to_screen(world_pt, center); - - // Draw point marker - let color = match pp.point_type { - PointType::LineTo => theme::POINT_LINE_TO, - PointType::CurveTo => theme::POINT_CURVE_TO, - PointType::CurveData => theme::POINT_CURVE_DATA, - }; - painter.circle_filled(screen_pt, 3.0, color); - } - } - } - - /// Draw point numbers using cached outlined digit textures (Houdini-style: bottom-right of point). - fn draw_point_numbers(&self, painter: &egui::Painter, path: &Path, center: Vec2) { - // Tight spacing between digits (characters are ~7px wide in the texture) - let digit_spacing = 7.0; - - for contour in &path.contours { - for (i, pp) in contour.points.iter().enumerate() { - let world_pt = Pos2::new(pp.point.x as f32, pp.point.y as f32); - let screen_pt = self.pan_zoom.world_to_screen(world_pt, center); - - // Position to the bottom-right of the point (like Houdini) - let mut x = screen_pt.x + 3.0; - let y = screen_pt.y + 2.0; - - // Draw each digit of the number - let num_str = i.to_string(); - for ch in num_str.chars() { - if let Some(digit) = ch.to_digit(10) { - if let Some(texture) = self.digit_cache.get(digit as usize) { - let rect = Rect::from_min_size( - Pos2::new(x, y), - Vec2::new(self.digit_cache.digit_width, self.digit_cache.digit_height), - ); - painter.image( - texture.id(), - rect, - Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), - Color32::WHITE, - ); - x += digit_spacing; - } - } - } - } - } - } - - /// Draw a path on the canvas. - fn draw_path(&self, painter: &egui::Painter, path: &Path, center: Vec2) { - for contour in &path.contours { - if contour.points.is_empty() { - continue; - } - - // Build the path points - let mut egui_points: Vec = Vec::new(); - let mut i = 0; - - while i < contour.points.len() { - let pp = &contour.points[i]; - let world_pt = Pos2::new(pp.point.x as f32, pp.point.y as f32); - let screen_pt = self.pan_zoom.world_to_screen(world_pt, center); - - match pp.point_type { - PointType::LineTo => { - egui_points.push(screen_pt); - i += 1; - } - PointType::CurveData => { - // CurveData is a control point - look ahead for the full cubic bezier - // Structure: CurveData (ctrl1), CurveData (ctrl2), CurveTo (end) - if i + 2 < contour.points.len() { - let ctrl1 = &contour.points[i]; - let ctrl2 = &contour.points[i + 1]; - let end = &contour.points[i + 2]; - - // Get start point (last point in egui_points, or first point of contour) - let start = egui_points.last().copied().unwrap_or(screen_pt); - - let c1 = self.world_to_screen(ctrl1.point, center); - let c2 = self.world_to_screen(ctrl2.point, center); - let e = self.world_to_screen(end.point, center); - - // Sample the cubic bezier - for t in 1..=10 { - let t = t as f32 / 10.0; - let pt = cubic_bezier(start, c1, c2, e, t); - egui_points.push(pt); - } - - i += 3; // Skip ctrl1, ctrl2, end - } else { - i += 1; - } - } - PointType::CurveTo => { - // Standalone CurveTo without preceding CurveData - treat as line - egui_points.push(screen_pt); - i += 1; - } - } - } - - if egui_points.len() < 2 { - continue; - } - - // Close the path if needed - if contour.closed && !egui_points.is_empty() { - egui_points.push(egui_points[0]); - } - - // Draw fill - if let Some(fill) = path.fill { - let fill_color = color_to_egui(fill); - if egui_points.len() >= 3 { - painter.add(egui::Shape::convex_polygon( - egui_points.clone(), - fill_color, - Stroke::NONE, - )); - } - } - - // Draw stroke - if let Some(stroke_color) = path.stroke { - let stroke = Stroke::new( - path.stroke_width as f32 * self.pan_zoom.zoom, - color_to_egui(stroke_color), - ); - painter.add(egui::Shape::line(egui_points, stroke)); - } else if path.fill.is_none() { - // If no fill and no stroke, draw a default stroke - let stroke = Stroke::new(1.0, egui::Color32::BLACK); - painter.add(egui::Shape::line(egui_points, stroke)); - } - } - } - - /// Convert a world point to screen coordinates. - fn world_to_screen(&self, point: Point, center: Vec2) -> Pos2 { - let world_pt = Pos2::new(point.x as f32, point.y as f32); - self.pan_zoom.world_to_screen(world_pt, center) - } - - /// Update handles for the selected node. - pub fn update_handles_for_node(&mut self, node_name: Option<&str>, state: &AppState) { - use crate::handles::{ellipse_handles, rect_four_point_handle, Handle}; - - match node_name { - Some(name) => { - if let Some(node) = state.library.root.child(name) { - let mut handle_set = HandleSet::new(name); - let mut use_four_point = false; - - if let Some(ref proto) = node.prototype { - match proto.as_str() { - "corevector.ellipse" => { - // Read from "position" Point port (per corevector.ndbx) - let position = node - .input("position") - .and_then(|p| p.value.as_point().cloned()) - .unwrap_or(Point::ZERO); - let width = node - .input("width") - .and_then(|p| p.value.as_float()) - .unwrap_or(100.0); - let height = node - .input("height") - .and_then(|p| p.value.as_float()) - .unwrap_or(100.0); - - for h in ellipse_handles(position.x, position.y, width, height) { - handle_set.add(h); - } - } - "corevector.rect" => { - // Read from "position" Point port (per corevector.ndbx) - let position = node - .input("position") - .and_then(|p| p.value.as_point().cloned()) - .unwrap_or(Point::ZERO); - let width = node - .input("width") - .and_then(|p| p.value.as_float()) - .unwrap_or(100.0); - let height = node - .input("height") - .and_then(|p| p.value.as_float()) - .unwrap_or(100.0); - - // Use FourPointHandle for rect nodes (only update if not dragging) - if self.four_point_handle.as_ref().map_or(true, |h| !h.is_dragging()) { - self.four_point_handle = Some(rect_four_point_handle(name, position.x, position.y, width, height)); - } - use_four_point = true; - } - "corevector.line" => { - let p1 = node - .input("point1") - .and_then(|p| p.value.as_point().cloned()) - .unwrap_or(Point::ZERO); - let p2 = node - .input("point2") - .and_then(|p| p.value.as_point().cloned()) - .unwrap_or(Point::new(100.0, 100.0)); - - handle_set.add( - Handle::point("point1", p1) - .with_color(Color32::from_rgb(255, 100, 100)), - ); - handle_set.add( - Handle::point("point2", p2) - .with_color(Color32::from_rgb(100, 255, 100)), - ); - } - "corevector.polygon" | "corevector.star" => { - // Read from "position" Point port (per corevector.ndbx) - let position = node - .input("position") - .and_then(|p| p.value.as_point().cloned()) - .unwrap_or(Point::ZERO); - - handle_set.add(Handle::point("position", position)); - } - _ => {} - } - } - - // FourPointHandle and regular handles are mutually exclusive - if use_four_point { - self.handles = None; - } else { - self.four_point_handle = None; - if !handle_set.handles().is_empty() { - self.handles = Some(handle_set); - } else { - self.handles = None; - } - } - } else { - self.handles = None; - self.four_point_handle = None; - } - } - None => { - self.handles = None; - self.four_point_handle = None; - } - } - } -} - -/// Convert a NodeBox color to an egui color. -fn color_to_egui(color: Color) -> egui::Color32 { - egui::Color32::from_rgba_unmultiplied( - (color.r * 255.0) as u8, - (color.g * 255.0) as u8, - (color.b * 255.0) as u8, - (color.a * 255.0) as u8, - ) -} - -/// Evaluate a cubic bezier curve at parameter t. -fn cubic_bezier(p0: Pos2, p1: Pos2, p2: Pos2, p3: Pos2, t: f32) -> Pos2 { - let t2 = t * t; - let t3 = t2 * t; - let mt = 1.0 - t; - let mt2 = mt * mt; - let mt3 = mt2 * mt; - - Pos2::new( - mt3 * p0.x + 3.0 * mt2 * t * p1.x + 3.0 * mt * t2 * p2.x + t3 * p3.x, - mt3 * p0.y + 3.0 * mt2 * t * p1.y + 3.0 * mt * t2 * p2.y + t3 * p3.y, - ) -} diff --git a/crates/nodebox-gui/tests/file_tests.rs b/crates/nodebox-gui/tests/file_tests.rs deleted file mode 100644 index b3cf7d7c..00000000 --- a/crates/nodebox-gui/tests/file_tests.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! Tests for loading and evaluating .ndbx files from the examples directory. - -use std::path::PathBuf; - -use nodebox_gui::eval::evaluate_network; -use nodebox_gui::{populate_default_ports, AppState}; - -/// Get the path to the examples directory. -fn examples_dir() -> PathBuf { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest_dir.parent().unwrap().parent().unwrap().join("examples") -} - -/// Load and parse an .ndbx file from the examples directory. -/// Also populates default ports for proper evaluation. -fn load_example(relative_path: &str) -> nodebox_core::node::NodeLibrary { - let path = examples_dir().join(relative_path); - let mut library = nodebox_ndbx::parse_file(&path).unwrap_or_else(|e| { - panic!("Failed to parse {}: {}", path.display(), e); - }); - // Populate default ports so connections work properly - populate_default_ports(&mut library.root); - library -} - -// ============================================================================ -// 01 Basics / 01 Shape -// ============================================================================ - -#[test] -fn test_load_primitives() { - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); - - // Verify basic structure - assert_eq!(library.root.name, "root"); - assert_eq!(library.root.rendered_child, Some("combine1".to_string())); - assert_eq!(library.width(), 1000.0); - assert_eq!(library.height(), 1000.0); - - // Verify nodes were loaded - assert!(!library.root.children.is_empty()); - - // Find specific nodes - let rect = library.root.child("rect1"); - assert!(rect.is_some(), "rect1 node should exist"); - assert_eq!( - rect.unwrap().prototype, - Some("corevector.rect".to_string()) - ); - - let ellipse = library.root.child("ellipse1"); - assert!(ellipse.is_some(), "ellipse1 node should exist"); - - let polygon = library.root.child("polygon1"); - assert!(polygon.is_some(), "polygon1 node should exist"); - - // Verify connections - assert!(!library.root.connections.is_empty()); -} - -#[test] -fn test_load_lines() { - let library = load_example("01 Basics/01 Shape/02 Lines/02 Lines.ndbx"); - - assert_eq!(library.root.name, "root"); - assert!(!library.root.children.is_empty()); -} - -#[test] -fn test_load_grid() { - let library = load_example("01 Basics/01 Shape/04 Grid/04 Grid.ndbx"); - - assert_eq!(library.root.name, "root"); - - // This file should have a grid node - let has_grid = library - .root - .children - .iter() - .any(|n| n.prototype.as_deref() == Some("corevector.grid")); - assert!(has_grid, "Should contain a grid node"); -} - -#[test] -fn test_load_copy() { - let library = load_example("01 Basics/01 Shape/05 Copy/05 Copy.ndbx"); - - assert_eq!(library.root.name, "root"); - - // This file should have a copy node - let has_copy = library - .root - .children - .iter() - .any(|n| n.prototype.as_deref() == Some("corevector.copy")); - assert!(has_copy, "Should contain a copy node"); -} - -#[test] -fn test_load_transformations() { - let library = load_example("01 Basics/01 Shape/06 Transformations/06 Transformations.ndbx"); - - assert_eq!(library.root.name, "root"); - assert!(!library.root.children.is_empty()); -} - -// ============================================================================ -// Evaluation tests - verify we can evaluate loaded files -// ============================================================================ - -#[test] -fn test_evaluate_primitives() { - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); - - // Create a library with just the rect node rendered - let mut test_library = nodebox_core::node::NodeLibrary::new("test"); - test_library.root = library.root.clone(); - test_library.root.rendered_child = Some("rect1".to_string()); - - let paths = evaluate_network(&test_library); - assert_eq!(paths.len(), 1, "rect1 should produce one path"); - - // Test ellipse - test_library.root.rendered_child = Some("ellipse1".to_string()); - let paths = evaluate_network(&test_library); - assert_eq!(paths.len(), 1, "ellipse1 should produce one path"); - - // Test polygon - test_library.root.rendered_child = Some("polygon1".to_string()); - let paths = evaluate_network(&test_library); - assert_eq!(paths.len(), 1, "polygon1 should produce one path"); -} - -#[test] -fn test_evaluate_primitives_full() { - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); - - // The rendered child is "combine1" which uses list.combine - // Now that list.combine is implemented, we can evaluate the full network - let paths = evaluate_network(&library); - - // Should have 3 shapes: rect, ellipse, polygon (each colorized) - assert_eq!(paths.len(), 3, "combine1 should produce 3 colorized paths"); - - // All paths should have fills (they go through colorize nodes) - for path in &paths { - assert!(path.fill.is_some(), "Each path should have a fill color"); - } -} - -#[test] -fn test_evaluate_colorized_primitives() { - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); - - let mut test_library = nodebox_core::node::NodeLibrary::new("test"); - test_library.root = library.root.clone(); - - // Test colorized rect (colorize1 <- rect1) - test_library.root.rendered_child = Some("colorize1".to_string()); - let paths = evaluate_network(&test_library); - - assert_eq!(paths.len(), 1, "colorize1 should produce one path"); - assert!(paths[0].fill.is_some(), "colorized path should have fill"); -} - -#[test] -fn test_evaluate_copy() { - let library = load_example("01 Basics/01 Shape/05 Copy/05 Copy.ndbx"); - - // Find a copy node and try to evaluate its output - let copy_node = library - .root - .children - .iter() - .find(|n| n.prototype.as_deref() == Some("corevector.copy")); - - if let Some(copy) = copy_node { - let mut test_library = nodebox_core::node::NodeLibrary::new("test"); - test_library.root = library.root.clone(); - test_library.root.rendered_child = Some(copy.name.clone()); - - let paths = evaluate_network(&test_library); - // Copy should produce multiple paths - assert!( - !paths.is_empty(), - "Copy node {} should produce paths", - copy.name - ); - } -} - -// ============================================================================ -// Color examples -// ============================================================================ - -#[test] -fn test_load_color_example() { - let path = examples_dir().join("01 Basics/02 Color"); - if path.exists() { - // Find any .ndbx file in color examples - if let Ok(entries) = std::fs::read_dir(&path) { - for entry in entries.flatten() { - let entry_path = entry.path(); - if entry_path.is_dir() { - if let Ok(files) = std::fs::read_dir(&entry_path) { - for file in files.flatten() { - if file - .path() - .extension() - .map_or(false, |e| e == "ndbx") - { - let library = nodebox_ndbx::parse_file(file.path()).unwrap(); - assert_eq!(library.root.name, "root"); - return; // Found and tested one file - } - } - } - } - } - } - } -} - -// ============================================================================ -// AppState::load_file integration test -// ============================================================================ - -#[test] -fn test_app_state_load_file() { - let mut state = AppState::new(); - - // Initially has demo content - assert!(!state.library.root.children.is_empty()); - - // Load the primitives example - let path = examples_dir().join("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); - let result = state.load_file(&path); - - assert!(result.is_ok(), "load_file should succeed"); - assert_eq!(state.current_file, Some(path.clone())); - assert!(!state.dirty); - - // Verify the library was loaded - assert_eq!(state.library.root.name, "root"); - assert!(state.library.root.child("rect1").is_some()); - assert!(state.library.root.child("ellipse1").is_some()); - assert!(state.library.root.child("polygon1").is_some()); - - // Verify geometry was evaluated (should have 3 shapes) - assert_eq!(state.geometry.len(), 3, "Should have 3 rendered shapes"); -} - -#[test] -fn test_app_state_load_file_nonexistent() { - let mut state = AppState::new(); - - let path = examples_dir().join("nonexistent.ndbx"); - let result = state.load_file(&path); - - assert!(result.is_err(), "load_file should fail for nonexistent file"); -} - -// ============================================================================ -// Position port tests - verify shapes respect the "position" Point port -// ============================================================================ - -#[test] -fn test_primitives_shapes_at_different_positions() { - // This test verifies that shapes loaded from the primitives example - // are at DIFFERENT positions, not all at the origin. - // The file defines: - // rect1: position="-100.00,0.00" - // ellipse1: position="10.00,0.00" - // polygon1: position="100.00,0.00" - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); - - // Evaluate rect1 alone - let mut test_library = nodebox_core::node::NodeLibrary::new("test"); - test_library.root = library.root.clone(); - test_library.root.rendered_child = Some("rect1".to_string()); - let rect_paths = evaluate_network(&test_library); - assert_eq!(rect_paths.len(), 1, "rect1 should produce one path"); - let rect_bounds = rect_paths[0].bounds().unwrap(); - let rect_center_x = rect_bounds.x + rect_bounds.width / 2.0; - - // Evaluate ellipse1 alone - test_library.root.rendered_child = Some("ellipse1".to_string()); - let ellipse_paths = evaluate_network(&test_library); - assert_eq!(ellipse_paths.len(), 1, "ellipse1 should produce one path"); - let ellipse_bounds = ellipse_paths[0].bounds().unwrap(); - let ellipse_center_x = ellipse_bounds.x + ellipse_bounds.width / 2.0; - - // Evaluate polygon1 alone - test_library.root.rendered_child = Some("polygon1".to_string()); - let polygon_paths = evaluate_network(&test_library); - assert_eq!(polygon_paths.len(), 1, "polygon1 should produce one path"); - let polygon_bounds = polygon_paths[0].bounds().unwrap(); - let polygon_center_x = polygon_bounds.x + polygon_bounds.width / 2.0; - - // Verify they are at DIFFERENT x positions as defined in the file - // rect1 should be at x=-100, ellipse1 at x=10, polygon1 at x=100 - assert!( - (rect_center_x - (-100.0)).abs() < 10.0, - "rect1 center X should be near -100, got {}", - rect_center_x - ); - assert!( - (ellipse_center_x - 10.0).abs() < 10.0, - "ellipse1 center X should be near 10, got {}", - ellipse_center_x - ); - assert!( - (polygon_center_x - 100.0).abs() < 10.0, - "polygon1 center X should be near 100, got {}", - polygon_center_x - ); - - // They should NOT all be at the same position (the bug we're catching) - assert!( - (rect_center_x - ellipse_center_x).abs() > 50.0, - "rect1 and ellipse1 should be at different positions! rect={}, ellipse={}", - rect_center_x, - ellipse_center_x - ); - assert!( - (ellipse_center_x - polygon_center_x).abs() > 50.0, - "ellipse1 and polygon1 should be at different positions! ellipse={}, polygon={}", - ellipse_center_x, - polygon_center_x - ); -} - -#[test] -fn test_position_port_is_point_type() { - // Verify that the loaded nodes have "position" port with Point type - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); - - let rect = library.root.child("rect1").expect("rect1 should exist"); - let position_port = rect.input("position"); - assert!( - position_port.is_some(), - "rect1 should have a 'position' port after loading" - ); - if let Some(port) = position_port { - match &port.value { - nodebox_core::Value::Point(p) => { - assert!( - (p.x - (-100.0)).abs() < 0.1, - "rect1 position.x should be -100, got {}", - p.x - ); - } - other => panic!("rect1 position should be Point type, got {:?}", other), - } - } - - let ellipse = library.root.child("ellipse1").expect("ellipse1 should exist"); - let position_port = ellipse.input("position"); - assert!( - position_port.is_some(), - "ellipse1 should have a 'position' port after loading" - ); - - let polygon = library.root.child("polygon1").expect("polygon1 should exist"); - let position_port = polygon.input("position"); - assert!( - position_port.is_some(), - "polygon1 should have a 'position' port after loading" - ); -} - -// ============================================================================ -// Bulk loading test - verify all example files can be parsed -// ============================================================================ - -#[test] -fn test_load_all_example_files() { - let examples = examples_dir(); - if !examples.exists() { - println!("Examples directory not found, skipping test"); - return; - } - - let mut loaded = 0; - let mut failed = Vec::new(); - - // Walk all directories - fn walk_dir(dir: &PathBuf, loaded: &mut usize, failed: &mut Vec<(PathBuf, String)>) { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - walk_dir(&path, loaded, failed); - } else if path.extension().map_or(false, |e| e == "ndbx") { - match nodebox_ndbx::parse_file(&path) { - Ok(library) => { - // Basic sanity check - assert!(!library.root.name.is_empty()); - *loaded += 1; - } - Err(e) => { - failed.push((path, e.to_string())); - } - } - } - } - } - } - - walk_dir(&examples, &mut loaded, &mut failed); - - println!("Loaded {} example files", loaded); - - if !failed.is_empty() { - println!("Failed to load {} files:", failed.len()); - for (path, err) in &failed { - println!(" {}: {}", path.display(), err); - } - } - - assert!(loaded > 0, "Should have loaded at least one example file"); - // Note: We don't assert on failed.is_empty() since some files may have - // features not yet implemented in the parser -} diff --git a/crates/nodebox-gui/tests/history_tests.rs b/crates/nodebox-gui/tests/history_tests.rs deleted file mode 100644 index b7eefe6a..00000000 --- a/crates/nodebox-gui/tests/history_tests.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Tests for undo/redo history functionality. - -mod common; - -use nodebox_gui::{History, Node, NodeLibrary, Port}; - -/// Create a simple test library with an ellipse. -fn create_test_library(x: f64) -> NodeLibrary { - let mut library = NodeLibrary::new("test"); - library.root = Node::network("root") - .with_child( - Node::new("ellipse1") - .with_prototype("corevector.ellipse") - .with_input(Port::float("x", x)) - .with_input(Port::float("y", 0.0)) - .with_input(Port::float("width", 100.0)) - .with_input(Port::float("height", 100.0)), - ) - .with_rendered_child("ellipse1"); - library -} - -#[test] -fn test_history_new_is_empty() { - let history = History::new(); - assert!(!history.can_undo()); - assert!(!history.can_redo()); - assert_eq!(history.undo_count(), 0); - assert_eq!(history.redo_count(), 0); -} - -#[test] -fn test_history_save_enables_undo() { - let mut history = History::new(); - let library = create_test_library(0.0); - - history.save_state(&library); - - assert!(history.can_undo()); - assert!(!history.can_redo()); - assert_eq!(history.undo_count(), 1); -} - -#[test] -fn test_history_undo_restores_previous_state() { - let mut history = History::new(); - - // Save initial state - let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); - - // Current state has different x value - let library_v2 = create_test_library(100.0); - - // Undo should restore v1 - let restored = history.undo(&library_v2).unwrap(); - - // Check that the x value was restored - let node = restored.root.child("ellipse1").unwrap(); - let x = node.input("x").unwrap().value.as_float().unwrap(); - assert!((x - 0.0).abs() < 0.001); -} - -#[test] -fn test_history_undo_enables_redo() { - let mut history = History::new(); - - let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); - - let library_v2 = create_test_library(100.0); - history.undo(&library_v2); - - assert!(history.can_redo()); - assert_eq!(history.redo_count(), 1); -} - -#[test] -fn test_history_redo_restores_undone_state() { - let mut history = History::new(); - - let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); - - let library_v2 = create_test_library(100.0); - - // Undo returns v1 - let after_undo = history.undo(&library_v2).unwrap(); - - // Redo should return v2 - let after_redo = history.redo(&after_undo).unwrap(); - - let node = after_redo.root.child("ellipse1").unwrap(); - let x = node.input("x").unwrap().value.as_float().unwrap(); - assert!((x - 100.0).abs() < 0.001); -} - -#[test] -fn test_history_new_changes_clear_redo_stack() { - let mut history = History::new(); - - let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); - - let library_v2 = create_test_library(100.0); - - // Undo to enable redo - history.undo(&library_v2); - assert!(history.can_redo()); - - // Save new state (simulating new change) - let library_v3 = create_test_library(50.0); - history.save_state(&library_v3); - - // Redo should now be unavailable - assert!(!history.can_redo()); - assert_eq!(history.redo_count(), 0); -} - -#[test] -fn test_history_multiple_undos() { - let mut history = History::new(); - - // Create and save multiple states - let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); - - let library_v2 = create_test_library(50.0); - history.save_state(&library_v2); - - let library_v3 = create_test_library(100.0); - - assert_eq!(history.undo_count(), 2); - - // Undo twice - let after_first_undo = history.undo(&library_v3).unwrap(); - let node = after_first_undo.root.child("ellipse1").unwrap(); - let x = node.input("x").unwrap().value.as_float().unwrap(); - assert!((x - 50.0).abs() < 0.001); - - let after_second_undo = history.undo(&after_first_undo).unwrap(); - let node = after_second_undo.root.child("ellipse1").unwrap(); - let x = node.input("x").unwrap().value.as_float().unwrap(); - assert!((x - 0.0).abs() < 0.001); - - // No more undos available - assert!(!history.can_undo()); -} - -#[test] -fn test_history_multiple_redos() { - let mut history = History::new(); - - let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); - - let library_v2 = create_test_library(50.0); - history.save_state(&library_v2); - - let library_v3 = create_test_library(100.0); - - // Undo twice - let after_first_undo = history.undo(&library_v3).unwrap(); - let after_second_undo = history.undo(&after_first_undo).unwrap(); - - assert_eq!(history.redo_count(), 2); - - // Redo twice - let after_first_redo = history.redo(&after_second_undo).unwrap(); - let node = after_first_redo.root.child("ellipse1").unwrap(); - let x = node.input("x").unwrap().value.as_float().unwrap(); - assert!((x - 50.0).abs() < 0.001); - - let after_second_redo = history.redo(&after_first_redo).unwrap(); - let node = after_second_redo.root.child("ellipse1").unwrap(); - let x = node.input("x").unwrap().value.as_float().unwrap(); - assert!((x - 100.0).abs() < 0.001); - - // No more redos available - assert!(!history.can_redo()); -} - -#[test] -fn test_history_clear() { - let mut history = History::new(); - - let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); - history.save_state(&library_v1); - history.save_state(&library_v1); - - assert_eq!(history.undo_count(), 3); - - history.clear(); - - assert!(!history.can_undo()); - assert!(!history.can_redo()); - assert_eq!(history.undo_count(), 0); - assert_eq!(history.redo_count(), 0); -} - -#[test] -fn test_history_mark_saved_and_unsaved_changes() { - let mut history = History::new(); - - let library_v1 = create_test_library(0.0); - history.mark_saved(&library_v1); - - // Same library should not have unsaved changes - assert!(!history.has_unsaved_changes(&library_v1)); - - // Different library should have unsaved changes - let library_v2 = create_test_library(100.0); - assert!(history.has_unsaved_changes(&library_v2)); -} - -#[test] -fn test_history_undo_on_empty_returns_none() { - let mut history = History::new(); - let library = create_test_library(0.0); - - let result = history.undo(&library); - assert!(result.is_none()); -} - -#[test] -fn test_history_redo_on_empty_returns_none() { - let mut history = History::new(); - let library = create_test_library(0.0); - - let result = history.redo(&library); - assert!(result.is_none()); -} diff --git a/crates/nodebox-ndbx/Cargo.toml b/crates/nodebox-ndbx/Cargo.toml deleted file mode 100644 index 983ae0af..00000000 --- a/crates/nodebox-ndbx/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "nodebox-ndbx" -description = "NDBX file format parser for NodeBox" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true - -[dependencies] -nodebox-core = { path = "../nodebox-core" } -quick-xml = "0.31" -thiserror = "1.0" - -[dev-dependencies] -proptest = { workspace = true } diff --git a/crates/nodebox-ndbx/src/lib.rs b/crates/nodebox-ndbx/src/lib.rs deleted file mode 100644 index 163f690d..00000000 --- a/crates/nodebox-ndbx/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! NDBX file format parser for NodeBox. -//! -//! This crate parses `.ndbx` files (XML-based) into NodeBox's internal -//! node graph representation. -//! -//! # Example -//! -//! ```no_run -//! use nodebox_ndbx::parse_file; -//! -//! let library = parse_file("examples/my_project.ndbx").unwrap(); -//! println!("Loaded library: {}", library.name); -//! ``` - -mod parser; -mod error; - -pub use error::{NdbxError, Result}; -pub use parser::{parse, parse_file}; diff --git a/crates/nodebox-ops/Cargo.toml b/crates/nodebox-ops/Cargo.toml deleted file mode 100644 index bf26e50c..00000000 --- a/crates/nodebox-ops/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "nodebox-ops" -description = "Geometry operations for NodeBox" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true - -[dependencies] -nodebox-core = { path = "../nodebox-core" } -rayon = "1.10" - -[features] -default = [] -parallel = [] - -[dev-dependencies] -proptest = { workspace = true } -approx = { workspace = true } diff --git a/crates/nodebox-python/Cargo.toml b/crates/nodebox-python/Cargo.toml index 743d0729..869b4fb5 100644 --- a/crates/nodebox-python/Cargo.toml +++ b/crates/nodebox-python/Cargo.toml @@ -13,7 +13,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] nodebox-core = { path = "../nodebox-core" } -nodebox-ops = { path = "../nodebox-ops" } # Python bindings pyo3 = { version = "0.22", features = ["extension-module", "abi3-py38"] } diff --git a/crates/nodebox-python/src/convert.rs b/crates/nodebox-python/src/convert.rs index 2b2dbc5a..4e35fec3 100644 --- a/crates/nodebox-python/src/convert.rs +++ b/crates/nodebox-python/src/convert.rs @@ -206,6 +206,8 @@ fn convert_path_like(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult CorePointType::CurveTo, "curvedata" | "curve_data" => CorePointType::CurveData, + "quadto" | "quad_to" => CorePointType::QuadTo, + "quaddata" | "quad_data" => CorePointType::QuadData, _ => CorePointType::LineTo, }; diff --git a/crates/nodebox-python/src/operations.rs b/crates/nodebox-python/src/operations.rs index 5fd74b0c..95fae2a6 100644 --- a/crates/nodebox-python/src/operations.rs +++ b/crates/nodebox-python/src/operations.rs @@ -2,7 +2,7 @@ use pyo3::prelude::*; use nodebox_core::geometry::{Point as CorePoint, Color as CoreColor}; -use nodebox_ops; +use nodebox_core::ops; use crate::types::{PyPoint, PyColor}; use crate::geometry::PyPath; @@ -20,7 +20,7 @@ pub fn py_ellipse( let w = width.unwrap_or(100.0); let h = height.unwrap_or(w); PyPath { - inner: nodebox_ops::ellipse(pos, w, h), + inner: ops::ellipse(pos, w, h), } } @@ -38,7 +38,7 @@ pub fn py_rect( let h = height.unwrap_or(w); let r = roundness.unwrap_or(0.0); PyPath { - inner: nodebox_ops::rect(pos, w, h, CorePoint::new(r, r)), + inner: ops::rect(pos, w, h, CorePoint::new(r, r)), } } @@ -54,7 +54,7 @@ pub fn py_line( let p2 = point2.map(|p| p.inner).unwrap_or(CorePoint::new(100.0, 100.0)); let pts = points.unwrap_or(2); PyPath { - inner: nodebox_ops::line(p1, p2, pts), + inner: ops::line(p1, p2, pts), } } @@ -70,7 +70,7 @@ pub fn py_polygon( let r = radius.unwrap_or(50.0); let s = sides.unwrap_or(6); PyPath { - inner: nodebox_ops::polygon(pos, r, s, true), + inner: ops::polygon(pos, r, s, true), } } @@ -88,7 +88,7 @@ pub fn py_star( let outer = outer_radius.unwrap_or(50.0); let inner = inner_radius.unwrap_or(25.0); PyPath { - inner: nodebox_ops::star(pos, pts, outer, inner), + inner: ops::star(pos, pts, outer, inner), } } @@ -110,7 +110,7 @@ pub fn py_arc( let deg = degrees.unwrap_or(360.0); let t = arc_type.unwrap_or("pie"); PyPath { - inner: nodebox_ops::arc(pos, w, h, start, deg, t), + inner: ops::arc(pos, w, h, start, deg, t), } } @@ -130,7 +130,7 @@ pub fn py_grid( let h = height.unwrap_or(200.0); let pos = position.map(|p| p.inner).unwrap_or(CorePoint::ZERO); - nodebox_ops::grid(c, r, w, h, pos) + ops::grid(c, r, w, h, pos) .into_iter() .map(PyPoint::from) .collect() @@ -148,7 +148,7 @@ pub fn py_translate( ) -> PyPath { let offset = CorePoint::new(tx.unwrap_or(0.0), ty.unwrap_or(0.0)); PyPath { - inner: nodebox_ops::translate(&path.inner, offset), + inner: ops::translate(&path.inner, offset), } } @@ -162,7 +162,7 @@ pub fn py_rotate( ) -> PyPath { let o = origin.map(|p| p.inner).unwrap_or(CorePoint::ZERO); PyPath { - inner: nodebox_ops::rotate(&path.inner, angle, o), + inner: ops::rotate(&path.inner, angle, o), } } @@ -179,7 +179,7 @@ pub fn py_scale( let scale_y = sy.unwrap_or(scale_x); let o = origin.map(|p| p.inner).unwrap_or(CorePoint::ZERO); PyPath { - inner: nodebox_ops::scale(&path.inner, CorePoint::new(scale_x, scale_y), o), + inner: ops::scale(&path.inner, CorePoint::new(scale_x, scale_y), o), } } @@ -196,6 +196,6 @@ pub fn py_colorize( let stroke_color = stroke.map(|c| c.inner).unwrap_or(CoreColor::BLACK); let sw = stroke_width.unwrap_or(1.0); PyPath { - inner: nodebox_ops::colorize(&path.inner, fill_color, stroke_color, sw), + inner: ops::colorize(&path.inner, fill_color, stroke_color, sw), } } diff --git a/crates/nodebox-svg/Cargo.toml b/crates/nodebox-svg/Cargo.toml deleted file mode 100644 index 21641ffd..00000000 --- a/crates/nodebox-svg/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "nodebox-svg" -description = "SVG rendering for NodeBox" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true - -[dependencies] -nodebox-core = { path = "../nodebox-core" } - -[dev-dependencies] -approx = { workspace = true } diff --git a/crates/nodebox-svg/src/lib.rs b/crates/nodebox-svg/src/lib.rs deleted file mode 100644 index 26a78b7c..00000000 --- a/crates/nodebox-svg/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! SVG rendering for NodeBox. -//! -//! This crate converts NodeBox geometry to SVG format. -//! -//! # Example -//! -//! ``` -//! use nodebox_core::geometry::{Path, Color}; -//! use nodebox_svg::render_to_svg; -//! -//! let path = Path::ellipse(100.0, 100.0, 80.0, 80.0); -//! let svg = render_to_svg(&[path], 200.0, 200.0); -//! println!("{}", svg); -//! ``` - -mod renderer; - -pub use renderer::*; diff --git a/docs/async_nodes.md b/docs/async_nodes.md new file mode 100644 index 00000000..50e2085b --- /dev/null +++ b/docs/async_nodes.md @@ -0,0 +1,231 @@ +# Async Node Implementation Guide + +This document explains how to implement async-aware nodes in NodeBox's Rust codebase, including proper cancellation support for long-running operations. + +## Overview + +NodeBox uses cooperative cancellation to allow users to stop long-running renders. When implementing nodes that perform I/O operations (file reading, network requests) or expensive computations, you should check for cancellation at appropriate boundaries. + +## Cancellation Architecture + +### How It Works + +1. **CancellationToken**: A thread-safe token shared between the main thread and render worker +2. **Cooperative checks**: Nodes check `is_cancelled()` at iteration boundaries +3. **Early return**: When cancelled, evaluation stops and returns cached partial results +4. **RAII cleanup**: Resources are automatically cleaned up via Rust's Drop trait + +### Cancellation Check Locations + +The evaluation engine automatically checks for cancellation at: +- **Before each node**: Before starting to evaluate any node +- **During list-matching iterations**: Between iterations in list-matching loops + +For standard nodes, this provides < 500ms response time for typical workloads. + +## Implementing Async-Aware Nodes + +### Basic Pattern + +For nodes that perform expensive operations: + +```rust +fn execute_expensive_node( + node: &Node, + inputs: &HashMap, + cancel_token: &CancellationToken, +) -> EvalResult { + // Check cancellation before starting + if cancel_token.is_cancelled() { + return Err(EvalError::Cancelled); + } + + // Do work in chunks, checking periodically + let mut results = Vec::new(); + for item in items { + // Check at iteration boundaries + if cancel_token.is_cancelled() { + return Err(EvalError::Cancelled); + } + + let result = process_item(item); + results.push(result); + } + + Ok(NodeOutput::from(results)) +} +``` + +### I/O Operations with smol + +For nodes that perform async I/O (file reading, network requests), use the `smol` runtime: + +```rust +use smol::fs; +use smol::future::block_on; + +fn execute_file_read_node( + node: &Node, + inputs: &HashMap, + cancel_token: &CancellationToken, +) -> EvalResult { + let path = get_string(inputs, "path", ""); + + // Use smol for async file operations + let content = block_on(async { + // Check cancellation before I/O + if cancel_token.is_cancelled() { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Cancelled", + )); + } + + fs::read_to_string(&path).await + }); + + match content { + Ok(text) => Ok(NodeOutput::String(text)), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => { + Err(EvalError::Cancelled) + } + Err(e) => Err(EvalError::ProcessingError(format!( + "{}: {}", + node.name, e + ))), + } +} +``` + +### Network Requests + +```rust +use smol::net::TcpStream; +use smol::io::AsyncReadExt; + +async fn fetch_url(url: &str, cancel_token: &CancellationToken) -> Result { + // Periodically check cancellation during long operations + if cancel_token.is_cancelled() { + return Err(EvalError::Cancelled); + } + + // Use async-net or smol's networking + let stream = TcpStream::connect(url).await + .map_err(|e| EvalError::ProcessingError(e.to_string()))?; + + // Read with cancellation checks + let mut buffer = Vec::new(); + // ... read in chunks, checking cancel_token between reads + + Ok(String::from_utf8_lossy(&buffer).to_string()) +} +``` + +## Best Practices + +### Check Frequency + +- **Too few checks**: User waits too long for cancellation (> 500ms) +- **Too many checks**: Performance overhead from atomic reads +- **Rule of thumb**: Check every ~100ms of work, or at natural iteration boundaries + +### Resource Cleanup + +Use Rust's RAII pattern for automatic cleanup: + +```rust +struct TempFile { + path: PathBuf, +} + +impl Drop for TempFile { + fn drop(&mut self) { + // Automatically cleaned up even on cancellation + let _ = std::fs::remove_file(&self.path); + } +} +``` + +### Cache Preservation + +When implementing caching, ensure partial results are preserved: + +```rust +// Good: Cache results as you go +for (i, item) in items.iter().enumerate() { + if cancel_token.is_cancelled() { + // partial_cache already contains results [0..i) + return Err(EvalError::Cancelled); + } + + let result = process(item); + partial_cache.insert(i, result.clone()); + results.push(result); +} + +// Bad: Only cache at the end +let results: Vec<_> = items.iter().map(process).collect(); +cache.insert_all(results); // Lost if cancelled +``` + +## Testing Async Nodes + +### Basic Cancellation Test + +```rust +#[test] +fn test_node_respects_cancellation() { + let token = CancellationToken::new(); + let mut cache = HashMap::new(); + + // Pre-cancel + token.cancel(); + + let result = evaluate_network_cancellable(&library, &token, &mut cache); + + assert!(matches!(result, EvalOutcome::Cancelled)); +} +``` + +### Response Time Test + +```rust +#[test] +fn test_cancellation_response_time() { + let token = CancellationToken::new(); + + let token_clone = token.clone(); + let handle = thread::spawn(move || { + let mut cache = HashMap::new(); + evaluate_network_cancellable(&library, &token_clone, &mut cache) + }); + + thread::sleep(Duration::from_millis(100)); + let cancel_time = Instant::now(); + token.cancel(); + + let _result = handle.join().unwrap(); + + assert!( + cancel_time.elapsed() < Duration::from_millis(500), + "Cancellation should respond within 500ms" + ); +} +``` + +## UI Integration + +The stop button in the address bar becomes prominent after 3 seconds of rendering: + +- **Idle**: SLATE_700 (subtle, near background) +- **Rendering < 3s**: SLATE_700 (subtle) +- **Rendering >= 3s**: SLATE_300 (prominent) + +Keyboard shortcut: `Cmd+.` (macOS) or `Ctrl+.` (Windows/Linux) + +## Related Files + +- `crates/nodebox-desktop/src/render_worker.rs` - CancellationToken, worker loop +- `crates/nodebox-desktop/src/eval.rs` - evaluate_network_cancellable() +- `crates/nodebox-desktop/src/address_bar.rs` - Stop button UI +- `crates/nodebox-desktop/tests/cancellation_tests.rs` - Integration tests diff --git a/docs/plans/wgpu-rendering-plan.md b/docs/plans/wgpu-rendering-plan.md index 8c826a42..43d9a0cc 100644 --- a/docs/plans/wgpu-rendering-plan.md +++ b/docs/plans/wgpu-rendering-plan.md @@ -18,7 +18,7 @@ Node Evaluation → Path/Contour Geometry → egui Painter → tiny-skia (CPU) - **pathfinder_geometry** - Geometry primitives (used in nodebox-core) ### Rendering Location -- `crates/nodebox-gui/src/viewer_pane.rs:714-803` - Manual path rendering +- `crates/nodebox-desktop/src/viewer_pane.rs:714-803` - Manual path rendering - Uses `egui::Painter` with `egui::Shape::line()` and `egui::Shape::convex_polygon()` - Cubic bezier curves manually sampled to line segments (10 samples per curve) @@ -65,7 +65,7 @@ Node Evaluation → Path Geometry → kurbo BezPath → Vello Scene → wgpu Tex ``` ┌─────────────────────────────────────────────────────────────────┐ -│ nodebox-gui │ +│ nodebox-desktop │ ├─────────────────────────────────────────────────────────────────┤ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ egui UI │ │ VelloViewer │ │ egui_wgpu │ │ @@ -119,7 +119,7 @@ impl From for peniko::Color { **Goal:** Set up wgpu infrastructure alongside existing rendering #### Tasks: -1. **Add dependencies to nodebox-gui/Cargo.toml:** +1. **Add dependencies to nodebox-desktop/Cargo.toml:** ```toml # GPU rendering vello = "0.4" @@ -130,12 +130,12 @@ impl From for peniko::Color { ``` 2. **Create geometry conversion module:** - - `crates/nodebox-gui/src/vello_convert.rs` + - `crates/nodebox-desktop/src/vello_convert.rs` - Implement `From<&Path>` for `kurbo::BezPath` - Implement color conversion 3. **Create Vello renderer wrapper:** - - `crates/nodebox-gui/src/vello_renderer.rs` + - `crates/nodebox-desktop/src/vello_renderer.rs` - Initialize Vello with shared wgpu device from egui - Render to texture method @@ -152,7 +152,7 @@ impl From for peniko::Color { #### Tasks: 1. **Create VelloViewer widget:** - - `crates/nodebox-gui/src/vello_viewer.rs` + - `crates/nodebox-desktop/src/vello_viewer.rs` - Manage wgpu texture lifecycle - Handle resize events - Display rendered texture in egui @@ -215,7 +215,7 @@ impl From for peniko::Color { ### New Files ``` -crates/nodebox-gui/src/ +crates/nodebox-desktop/src/ ├── vello_convert.rs # Geometry conversion (Path → kurbo) ├── vello_renderer.rs # Vello wrapper and texture management ├── vello_viewer.rs # Vello-powered viewer widget @@ -224,7 +224,7 @@ crates/nodebox-gui/src/ ### Modified Files ``` -crates/nodebox-gui/ +crates/nodebox-desktop/ ├── Cargo.toml # Add vello, wgpu dependencies ├── src/app.rs # Initialize GPU context ├── src/viewer_pane.rs # Switch between CPU/GPU rendering diff --git a/docs/rust-translation-plan.md b/docs/rust-translation-plan.md index bf083d5d..9f7df042 100644 --- a/docs/rust-translation-plan.md +++ b/docs/rust-translation-plan.md @@ -28,7 +28,7 @@ nodebox/ ├── Cargo.toml # Rust workspace root ├── Cargo.lock ├── crates/ # Rust crates -│ ├── nodebox-core/ # Core types and traits +│ ├── nodebox-core/ # Core types, operations, and formats │ │ ├── src/ │ │ │ ├── lib.rs │ │ │ ├── geometry/ # Graphics primitives @@ -45,43 +45,40 @@ nodebox/ │ │ │ ├── node/ # Node graph model │ │ │ │ ├── mod.rs │ │ │ │ ├── node.rs -│ │ │ │ ├── port.rs +│ │ │ │ ├── port.rs # Port types (merged from nodebox-port) │ │ │ │ ├── connection.rs │ │ │ │ ├── library.rs │ │ │ │ └── context.rs # Evaluation context -│ │ │ └── value.rs # Runtime value types +│ │ │ ├── ops/ # Built-in operations (merged from nodebox-ops) +│ │ │ │ ├── generators.rs # Generator operations +│ │ │ │ ├── filters.rs # Filter operations +│ │ │ │ ├── math.rs # 41 math operations +│ │ │ │ ├── list.rs # 21 list operations +│ │ │ │ ├── string.rs # 24 string operations +│ │ │ │ └── color.rs # 4 color operations +│ │ │ ├── ndbx/ # NDBX file format (merged from nodebox-ndbx) +│ │ │ │ ├── parser.rs # XML parsing (quick-xml) +│ │ │ │ ├── writer.rs # XML serialization +│ │ │ │ └── upgrade.rs # Version migration +│ │ │ ├── svg/ # SVG renderer (merged from nodebox-svg) +│ │ │ │ └── mod.rs +│ │ │ ├── platform.rs # Platform definitions (merged from nodebox-port) +│ │ │ └── value.rs # Runtime value types │ │ └── Cargo.toml │ │ -│ ├── nodebox-ops/ # Built-in operations (functions) +│ ├── nodebox-desktop/ # Desktop GUI application (renamed from nodebox-gui) │ │ ├── src/ -│ │ │ ├── lib.rs -│ │ │ ├── corevector.rs # 58 vector operations -│ │ │ ├── math.rs # 41 math operations -│ │ │ ├── list.rs # 21 list operations -│ │ │ ├── string.rs # 24 string operations -│ │ │ ├── data.rs # 5 data operations -│ │ │ └── color.rs # 4 color operations -│ │ └── Cargo.toml -│ │ -│ ├── nodebox-ndbx/ # NDBX file format parser/writer -│ │ ├── src/ -│ │ │ ├── lib.rs -│ │ │ ├── parser.rs # XML parsing (quick-xml) -│ │ │ ├── writer.rs # XML serialization -│ │ │ └── upgrade.rs # Version migration +│ │ │ ├── main.rs +│ │ │ ├── app.rs +│ │ │ ├── node_library.rs +│ │ │ ├── eval.rs +│ │ │ ├── theme.rs +│ │ │ └── ... │ │ └── Cargo.toml │ │ -│ ├── nodebox-render/ # Output renderers -│ │ ├── src/ -│ │ │ ├── lib.rs -│ │ │ ├── svg.rs # SVG renderer -│ │ │ ├── pdf.rs # PDF renderer (optional) -│ │ │ └── csv.rs # CSV data export -│ │ └── Cargo.toml -│ │ -│ └── nodebox-cli/ # Command-line interface +│ └── nodebox-python/ # Python integration (PyO3) │ ├── src/ -│ │ └── main.rs +│ │ └── lib.rs │ └── Cargo.toml │ ├── tests/ # Rust integration tests @@ -344,7 +341,7 @@ impl<'a> NodeContext<'a> { } ``` -### 1.4 Operations (`nodebox-ops`) +### 1.4 Operations (`nodebox-core::ops`) #### 1.4.1 Function Registry @@ -461,7 +458,7 @@ impl Geometry { - `compound` - Boolean ops on path pairs (with careful synchronization) - List `map` operations - Via `rayon::par_iter()` -### 1.5 NDBX Parser (`nodebox-ndbx`) +### 1.5 NDBX Parser (`nodebox-core::ndbx`) #### 1.5.1 Format Structure @@ -589,7 +586,9 @@ impl SvgRenderer { } ``` -### 1.7 CLI Application (`nodebox-cli`) +### 1.7 CLI Application (removed) + +> **Note:** The `nodebox-cli` crate has been removed. CLI functionality was consolidated into the main application. ```rust use clap::Parser; @@ -1014,7 +1013,7 @@ For a NodeBox-style application, egui is the best fit because: ### 2.3 GUI Architecture ``` -crates/nodebox-gui/ +crates/nodebox-desktop/ ├── src/ │ ├── main.rs │ ├── app.rs # Main application state @@ -1685,14 +1684,14 @@ Phase 3: Python (1-2 months) All three phases have been implemented: ### Phase 1: Core Library ✅ -- `nodebox-core`: Geometry primitives, node model, value types -- `nodebox-ops`: 150+ built-in operations (generators, transforms, filters) -- `nodebox-ndbx`: NDBX file format parser and writer -- `nodebox-svg`: SVG renderer -- `nodebox-cli`: Command-line interface +- `nodebox-core`: Geometry primitives, node model, value types, plus: + - `nodebox-core::ops`: 150+ built-in operations (generators, transforms, filters) — merged from `nodebox-ops` + - `nodebox-core::ndbx`: NDBX file format parser and writer — merged from `nodebox-ndbx` + - `nodebox-core::svg`: SVG renderer — merged from `nodebox-svg` + - `nodebox-core::platform`: Platform definitions — merged from `nodebox-port` ### Phase 2: GUI ✅ -- `nodebox-gui`: egui-based GUI application with: +- `nodebox-desktop`: egui-based desktop GUI application (renamed from `nodebox-gui`) with: - Canvas viewer with geometry rendering - Node graph editor with visual editing - Port/parameter editor panel @@ -1721,4 +1720,4 @@ cargo build -p nodebox-python The existing `pyvector.py` uses Java-specific APIs (java.awt.geom, etc.) that would require a compatibility layer to work with the Rust implementation. -The core NodeBox functions are available through the Rust nodebox-ops crate. +The core NodeBox functions are available through the Rust nodebox-core crate (in the ops module). diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 00000000..d8d4029e --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,458 @@ +# NodeBox Server API + +REST API specification for the NodeBox web backend. + +## Base URL + +``` +/api/v1 +``` + +## Authentication + +All endpoints may require authentication via Bearer token: + +``` +Authorization: Bearer +``` + +Authentication implementation is deployment-specific and not specified here. + +--- + +## Projects + +### List Projects + +``` +GET /projects +``` + +**Response:** + +```json +{ + "projects": [ + { + "id": "abc123", + "name": "My Project", + "modified": "2024-01-15T10:30:00Z" + } + ] +} +``` + +### Get Project + +``` +GET /projects/{id} +``` + +**Response:** Raw `.ndbx` file content (`application/xml`) + +### Create Project + +``` +POST /projects +Content-Type: application/xml + +... +``` + +**Response:** + +```json +{ + "id": "abc123", + "name": "Untitled" +} +``` + +### Update Project + +``` +PUT /projects/{id} +Content-Type: application/xml + +... +``` + +**Response:** `204 No Content` + +### Delete Project + +``` +DELETE /projects/{id} +``` + +**Response:** `204 No Content` + +--- + +## Assets + +Assets are files within a project directory (images, fonts, data files). + +### List Assets + +``` +GET /projects/{id}/assets +GET /projects/{id}/assets/{path} # List subdirectory +``` + +**Response:** + +```json +{ + "entries": [ + { "name": "logo.png", "is_directory": false }, + { "name": "fonts", "is_directory": true } + ] +} +``` + +### Get Asset + +``` +GET /projects/{id}/assets/{path} +Accept: application/octet-stream +``` + +**Response:** Raw file bytes + +### Upload Asset + +``` +PUT /projects/{id}/assets/{path} +Content-Type: application/octet-stream + + +``` + +**Response:** `201 Created` + +### Delete Asset + +``` +DELETE /projects/{id}/assets/{path} +``` + +**Response:** `204 No Content` + +--- + +## Libraries + +Libraries are shared node collections available to all projects. + +### List Libraries + +``` +GET /libraries +``` + +**Response:** + +```json +{ + "libraries": [ + { "name": "math", "version": "1.0.0" }, + { "name": "color", "version": "2.1.0" } + ] +} +``` + +### Get Library + +``` +GET /libraries/{name} +``` + +**Response:** Raw library file content (`application/xml`) + +--- + +## Error Responses + +All errors return JSON: + +```json +{ + "error": "not_found", + "message": "Project not found" +} +``` + +**Error codes:** + +| Status | Description | +|--------|-------------| +| `400` | Bad request (invalid input) | +| `401` | Unauthorized (missing/invalid token) | +| `403` | Forbidden (no access to resource) | +| `404` | Not found | +| `500` | Internal server error | + +--- + +## WebPlatform Implementation Notes + +The JavaScript WebPlatform implementation maps Platform trait methods to API calls: + +```javascript +const webPlatform = { + async read_file(ctx, path) { + const resp = await fetch(`/api/v1/projects/${ctx.project_id}/assets/${path}`); + if (!resp.ok) throw new PlatformError(resp.status === 404 ? 'NotFound' : 'IoError'); + return new Uint8Array(await resp.arrayBuffer()); + }, + + async write_file(ctx, path, data) { + const resp = await fetch(`/api/v1/projects/${ctx.project_id}/assets/${path}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + body: data + }); + if (!resp.ok) throw new PlatformError('IoError'); + }, + + async list_directory(ctx, path) { + const resp = await fetch(`/api/v1/projects/${ctx.project_id}/assets/${path}`); + if (!resp.ok) throw new PlatformError(resp.status === 404 ? 'NotFound' : 'IoError'); + return (await resp.json()).entries; + }, + + async read_project(ctx) { + const resp = await fetch(`/api/v1/projects/${ctx.project_id}`); + if (!resp.ok) throw new PlatformError(resp.status === 404 ? 'NotFound' : 'IoError'); + return new Uint8Array(await resp.arrayBuffer()); + }, + + async write_project(ctx, data) { + const resp = await fetch(`/api/v1/projects/${ctx.project_id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/xml' }, + body: data + }); + if (!resp.ok) throw new PlatformError('IoError'); + }, + + async load_library(name) { + const resp = await fetch(`/api/v1/libraries/${name}`); + if (!resp.ok) throw new PlatformError(resp.status === 404 ? 'LibraryNotFound' : 'IoError'); + return new Uint8Array(await resp.arrayBuffer()); + }, + + async http_get(url) { + const resp = await fetch(url); + if (!resp.ok) throw new PlatformError('NetworkError'); + return new Uint8Array(await resp.arrayBuffer()); + }, + + // === Project Dialogs (return absolute paths, no sandbox) === + + // For opening project files - returns absolute path + async show_open_project_dialog(filters) { + // Use File System Access API if available + if ('showOpenFilePicker' in window) { + try { + const types = filters.map(f => ({ + description: f.name, + accept: { 'application/octet-stream': f.extensions.map(e => `.${e}`) } + })); + const [handle] = await window.showOpenFilePicker({ types }); + return handle.name; // In web context, return just the name + } catch (e) { + if (e.name === 'AbortError') return null; + throw e; + } + } + // Fallback: use input element + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = filters.flatMap(f => f.extensions.map(e => `.${e}`)).join(','); + input.onchange = () => resolve(input.files?.[0]?.name ?? null); + input.oncancel = () => resolve(null); + input.click(); + }); + }, + + // For saving project files - returns absolute path + async show_save_project_dialog(filters, defaultName) { + if ('showSaveFilePicker' in window) { + try { + const types = filters.map(f => ({ + description: f.name, + accept: { 'application/octet-stream': f.extensions.map(e => `.${e}`) } + })); + const handle = await window.showSaveFilePicker({ types, suggestedName: defaultName }); + return handle.name; + } catch (e) { + if (e.name === 'AbortError') return null; + throw e; + } + } + // Fallback: return suggested name and let caller handle download + return defaultName ?? 'untitled.ndbx'; + }, + + // === Asset Dialogs (sandboxed to project directory, return relative paths) === + + // For importing assets within project - requires ProjectContext, returns RelativePath + async show_open_file_dialog(ctx, filters) { + // Note: In web context, ctx.project_id is used to validate selections + // This is a security measure - files must be within the project + if ('showOpenFilePicker' in window) { + try { + const types = filters.map(f => ({ + description: f.name, + accept: { 'application/octet-stream': f.extensions.map(e => `.${e}`) } + })); + const [handle] = await window.showOpenFilePicker({ types }); + // In a real implementation, validate the path is within project + // For REST API mode, the server handles validation + return handle.name; + } catch (e) { + if (e.name === 'AbortError') return null; + throw e; + } + } + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = filters.flatMap(f => f.extensions.map(e => `.${e}`)).join(','); + input.onchange = () => resolve(input.files?.[0]?.name ?? null); + input.oncancel = () => resolve(null); + input.click(); + }); + }, + + // For exporting assets within project - requires ProjectContext, returns RelativePath + async show_save_file_dialog(ctx, filters, defaultName) { + // Note: In web context, the save location is validated to be within project + if ('showSaveFilePicker' in window) { + try { + const types = filters.map(f => ({ + description: f.name, + accept: { 'application/octet-stream': f.extensions.map(e => `.${e}`) } + })); + const handle = await window.showSaveFilePicker({ types, suggestedName: defaultName }); + return handle.name; + } catch (e) { + if (e.name === 'AbortError') return null; + throw e; + } + } + // Fallback: return suggested name + return defaultName ?? 'untitled'; + }, + + // For selecting folders within project - requires ProjectContext, returns RelativePath + async show_select_folder_dialog(ctx) { + if ('showDirectoryPicker' in window) { + try { + const handle = await window.showDirectoryPicker(); + // In a real implementation, validate the folder is within project + return handle.name; + } catch (e) { + if (e.name === 'AbortError') return null; + throw e; + } + } + throw new PlatformError('Unsupported'); + }, + + async show_confirm_dialog(title, message) { + return window.confirm(`${title}\n\n${message}`); + }, + + async show_message_dialog(title, message, buttons) { + // Simple implementation using confirm/alert + // For better UX, use a modal library + const result = window.confirm(`${title}\n\n${message}\n\n${buttons.join(' / ')}`); + return result ? 0 : buttons.length - 1; + }, + + async clipboard_read_text() { + try { + return await navigator.clipboard.readText(); + } catch { + return null; + } + }, + + async clipboard_write_text(text) { + await navigator.clipboard.writeText(text); + }, + + log(level, message) { + const levels = { Error: 'error', Warn: 'warn', Info: 'info', Debug: 'debug' }; + console[levels[level] || 'log'](message); + }, + + performance_mark(name) { + performance.mark(name); + }, + + performance_mark_with_details(name, details) { + performance.mark(name, { detail: JSON.parse(details) }); + }, + + get_config_dir() { + // Web has no config directory; return a virtual path + throw new PlatformError('Unsupported'); + }, + + platform_info() { + return { + os_name: 'web', + is_web: true, + is_mobile: /Android|iPhone|iPad/i.test(navigator.userAgent), + has_filesystem: 'showOpenFilePicker' in window, + has_native_dialogs: false + }; + } +}; +``` + +### File System Access API + +For browsers that support the File System Access API (Chrome, Edge), the WebPlatform can provide a more native-like experience: + +```javascript +class FileSystemAccessPlatform { + constructor(directoryHandle) { + this.root = directoryHandle; + } + + async read_file(ctx, path) { + const parts = path.split('/'); + let handle = this.root; + + for (let i = 0; i < parts.length - 1; i++) { + handle = await handle.getDirectoryHandle(parts[i]); + } + + const fileHandle = await handle.getFileHandle(parts[parts.length - 1]); + const file = await fileHandle.getFile(); + return new Uint8Array(await file.arrayBuffer()); + } + + async write_file(ctx, path, data) { + const parts = path.split('/'); + let handle = this.root; + + for (let i = 0; i < parts.length - 1; i++) { + handle = await handle.getDirectoryHandle(parts[i], { create: true }); + } + + const fileHandle = await handle.getFileHandle(parts[parts.length - 1], { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(data); + await writable.close(); + } + + // ... other methods +} +``` diff --git a/scripts/build-linux-appimage.sh b/scripts/build-linux-appimage.sh index ff4e8c52..c91f1c23 100755 --- a/scripts/build-linux-appimage.sh +++ b/scripts/build-linux-appimage.sh @@ -37,10 +37,10 @@ fi # Build the binary cd "$PROJECT_ROOT" -cargo build $CARGO_FLAGS -p nodebox-gui +cargo build $CARGO_FLAGS -p nodebox # Set up paths -BINARY_PATH="$PROJECT_ROOT/target/$BUILD_TYPE/nodebox-gui" +BINARY_PATH="$PROJECT_ROOT/target/$BUILD_TYPE/NodeBox" APPDIR="$PROJECT_ROOT/target/$BUILD_TYPE/NodeBox.AppDir" APPIMAGE_PATH="$PROJECT_ROOT/target/$BUILD_TYPE/NodeBox-$VERSION-$ARCH.AppImage" diff --git a/scripts/build-mac-bundle.sh b/scripts/build-mac-bundle.sh index 3ca072d0..d04ad478 100755 --- a/scripts/build-mac-bundle.sh +++ b/scripts/build-mac-bundle.sh @@ -21,7 +21,7 @@ echo "Building NodeBox $VERSION ($BUILD_TYPE)..." # Build the binary cd "$PROJECT_ROOT" -cargo build $CARGO_FLAGS +cargo build $CARGO_FLAGS -p nodebox # Set up paths BINARY_PATH="$PROJECT_ROOT/target/$BUILD_TYPE/NodeBox" diff --git a/scripts/build-windows-installer.ps1 b/scripts/build-windows-installer.ps1 index d0d06e36..5e599660 100644 --- a/scripts/build-windows-installer.ps1 +++ b/scripts/build-windows-installer.ps1 @@ -30,9 +30,9 @@ $CargoFlags = if ($Debug) { @() } else { @("--release") } # Build the binary Set-Location $ProjectRoot -cargo build @CargoFlags -p nodebox-gui +cargo build @CargoFlags -p nodebox -$BinaryPath = "$ProjectRoot\target\$BuildType\nodebox-gui.exe" +$BinaryPath = "$ProjectRoot\target\$BuildType\NodeBox.exe" $OutputDir = "$ProjectRoot\target\$BuildType\installer" # Create output directory @@ -58,6 +58,6 @@ Write-Host "" Write-Host "To create an MSI installer:" Write-Host " 1. Install WiX Toolset: https://wixtoolset.org/" Write-Host " 2. Install cargo-wix: cargo install cargo-wix" -Write-Host " 3. Run: cargo wix -p nodebox-gui" +Write-Host " 3. Run: cargo wix -p nodebox" Write-Host "" Write-Host "Or use the portable executable directly: $OutputDir\NodeBox.exe" diff --git a/src/main.rs b/src/main.rs index 332e9d48..9cc5de5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,3 @@ fn main() -> eframe::Result<()> { - nodebox_gui::run() + nodebox_desktop::run() }