From 86a9a4517082c96dc2ca4e029dae1de2911ac2f2 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 11:38:25 +0100 Subject: [PATCH 001/100] Implement Open Recent menu functionality Add "Open Recent" submenu to the File menu that tracks recently opened files. Features include: - Recent files persisted to platform-specific config directory as JSON - Maximum 10 entries, most recent at top - Opening a file moves it to top of list - Non-existent files filtered from display - "Clear Recent" option to empty the list - Works on both macOS native menu and egui fallback menu Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 130 +++++++++++++++- crates/nodebox-gui/Cargo.toml | 5 + crates/nodebox-gui/src/app.rs | 80 ++++++++++ crates/nodebox-gui/src/lib.rs | 1 + crates/nodebox-gui/src/native_menu.rs | 111 ++++++++++++-- crates/nodebox-gui/src/recent_files.rs | 199 +++++++++++++++++++++++++ 6 files changed, 509 insertions(+), 17 deletions(-) create mode 100644 crates/nodebox-gui/src/recent_files.rs diff --git a/Cargo.lock b/Cargo.lock index b09f3c5e..78afe139 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1092,13 +1092,34 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[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 +1130,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2424,6 +2445,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" @@ -2879,6 +2906,7 @@ dependencies = [ name = "nodebox-gui" version = "0.1.0" dependencies = [ + "directories", "eframe", "egui", "egui-wgpu", @@ -2894,6 +2922,8 @@ dependencies = [ "nodebox-svg", "pollster", "rfd", + "serde", + "serde_json", "tempfile", "tiny-skia", "vello", @@ -4048,6 +4078,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" @@ -4259,6 +4300,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" @@ -5528,6 +5582,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 +5642,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 +5705,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 +5729,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 +5753,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 +5789,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 +5813,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 +5837,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 +5861,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" @@ -6134,6 +6254,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/crates/nodebox-gui/Cargo.toml b/crates/nodebox-gui/Cargo.toml index 6c4c9d3c..49fcd73c 100644 --- a/crates/nodebox-gui/Cargo.toml +++ b/crates/nodebox-gui/Cargo.toml @@ -26,6 +26,11 @@ 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" env_logger = "0.11" diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index e66c3e17..f1a319f0 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -8,6 +8,7 @@ use crate::components; use crate::history::History; use crate::icon_cache::IconCache; use crate::native_menu::{MenuAction, NativeMenuHandle}; +use crate::recent_files::RecentFiles; use crate::network_view::{NetworkAction, NetworkView}; use crate::node_selection_dialog::NodeSelectionDialog; use crate::panels::ParameterPanel; @@ -38,6 +39,8 @@ pub struct NodeBoxApp { render_pending: bool, /// Native menu handle for macOS system menu bar. native_menu: Option, + /// Recent files list for "Open Recent" menu. + recent_files: RecentFiles, } impl NodeBoxApp { @@ -58,13 +61,25 @@ impl NodeBoxApp { let mut state = AppState::new(); + // Load recent files from disk + let mut recent_files = RecentFiles::load(); + // 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); + } else { + // Add to recent files on successful load + recent_files.add_file(path.clone()); + recent_files.save(); } } + // 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); Self { state, @@ -81,6 +96,7 @@ impl NodeBoxApp { render_state: RenderState::new(), render_pending: false, // Initial geometry is already evaluated in AppState::new() native_menu, + recent_files, } } @@ -108,6 +124,7 @@ impl NodeBoxApp { render_state: RenderState::new(), render_pending: false, native_menu: None, + recent_files: RecentFiles::new(), } } @@ -136,6 +153,7 @@ impl NodeBoxApp { render_state: RenderState::new(), render_pending: false, native_menu: None, + recent_files: RecentFiles::new(), } } @@ -262,6 +280,8 @@ impl NodeBoxApp { 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(), @@ -294,6 +314,9 @@ impl NodeBoxApp { /// 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::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { @@ -304,6 +327,32 @@ impl NodeBoxApp { self.open_file(); ui.close_menu(); } + 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_menu(); + } + } + 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_menu(); + } + }); if ui.button("Save").clicked() { self.save_file(); ui.close_menu(); @@ -687,10 +736,39 @@ impl NodeBoxApp { { if let Err(e) = self.state.load_file(&path) { log::error!("Failed to load file: {}", e); + } else { + self.add_to_recent_files(path); } } } + /// 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 { + 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) { @@ -708,6 +786,8 @@ impl NodeBoxApp { { if let Err(e) = self.state.save_file(&path) { log::error!("Failed to save file: {}", e); + } else { + self.add_to_recent_files(path); } } } diff --git a/crates/nodebox-gui/src/lib.rs b/crates/nodebox-gui/src/lib.rs index 945b8fd0..6ba49a5d 100644 --- a/crates/nodebox-gui/src/lib.rs +++ b/crates/nodebox-gui/src/lib.rs @@ -67,6 +67,7 @@ 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; diff --git a/crates/nodebox-gui/src/native_menu.rs b/crates/nodebox-gui/src/native_menu.rs index a12c2001..5659462b 100644 --- a/crates/nodebox-gui/src/native_menu.rs +++ b/crates/nodebox-gui/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/recent_files.rs b/crates/nodebox-gui/src/recent_files.rs new file mode 100644 index 00000000..304a9a5b --- /dev/null +++ b/crates/nodebox-gui/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); + } +} From f6261e494b8853a66f5fa85cb692ae6811e0c9e9 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 13:21:54 +0100 Subject: [PATCH 002/100] Add quad_curve node input ports and library entry The quad_curve node was missing its input port definitions, causing the parameter pane to not show the expected inputs. Added port definitions to both state.rs (for existing nodes) and node_library.rs (for creating new nodes). Co-Authored-By: Claude Opus 4.5 --- crates/nodebox-gui/src/node_library.rs | 13 +++++++++++++ crates/nodebox-gui/src/state.rs | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 0d378039..4f7acb50 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -55,6 +55,12 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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", @@ -274,6 +280,13 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::float("degrees", 45.0)) .with_input(Port::string("type", "pie")); } + "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)); + } "grid" => { node = node .with_input(Port::int("columns", 10)) diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index 925064ec..88c8b9c2 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -277,6 +277,12 @@ pub fn populate_default_ports(node: &mut Node) { 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)); + } _ => {} } } From 88e55bd71ce06ddae48ca11a407ca9d2303aa8e2 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 14:24:02 +0100 Subject: [PATCH 003/100] Add Result-based error handling for node evaluation - Extend EvalError with ProcessingError(String) variant - Change evaluate_node() and execute_node() to return Result - Missing required inputs produce EvalError::PortNotFound - Errors abort entire network evaluation and propagate through connections - Add NodeError struct with node_name and message for UI display - Store per-node errors in AppState HashMap - Display error nodes with red background (#ff6467) in network view - Show error message tooltip on hover for error nodes - Keep last successful geometry in viewer when errors occur - Add unit tests for error propagation and handling Co-Authored-By: Claude Opus 4.5 --- crates/nodebox-core/src/node/mod.rs | 4 + crates/nodebox-gui/src/app.rs | 29 +- crates/nodebox-gui/src/eval.rs | 515 +++++++++++++++--------- crates/nodebox-gui/src/network_view.rs | 26 +- crates/nodebox-gui/src/render_worker.rs | 10 +- crates/nodebox-gui/src/state.rs | 37 +- crates/nodebox-gui/src/theme.rs | 2 +- crates/nodebox-gui/tests/file_tests.rs | 18 +- 8 files changed, 425 insertions(+), 216 deletions(-) diff --git a/crates/nodebox-core/src/node/mod.rs b/crates/nodebox-core/src/node/mod.rs index b82d05d5..d8f6b562 100644 --- a/crates/nodebox-core/src/node/mod.rs +++ b/crates/nodebox-core/src/node/mod.rs @@ -38,6 +38,9 @@ pub enum EvalError { /// An error occurred in a Python function. PythonError(String), + /// An error occurred during node processing. + ProcessingError(String), + /// A general evaluation error. Other(String), } @@ -57,6 +60,7 @@ 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::Other(msg) => write!(f, "{}", msg), } } diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index f1a319f0..2c1774f5 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -248,10 +248,29 @@ impl NodeBoxApp { 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(); + match result { + RenderResult::Success { id, geometry, 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_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::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(); + } } } } @@ -545,7 +564,7 @@ impl eframe::App for NodeBoxApp { } // Network view - let action = self.network_view.show(ui, &mut self.state.library); + let action = self.network_view.show(ui, &mut self.state.library, &self.state.node_errors); // Handle network actions match action { diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 684732b1..3971ec62 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -2,10 +2,32 @@ use std::collections::HashMap; use nodebox_core::geometry::{Path, Point, Color, Contour, PathPoint, PointType}; -use nodebox_core::node::{Node, NodeLibrary}; +use nodebox_core::node::{Node, NodeLibrary, EvalError}; use nodebox_core::node::PortRange; use nodebox_core::Value; +/// 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; + /// The result of evaluating a node. #[derive(Clone, Debug)] pub enum NodeOutput { @@ -108,8 +130,10 @@ impl NodeOutput { } } -/// Evaluate a node network and return the output of the rendered node. -pub fn evaluate_network(library: &NodeLibrary) -> Vec { +/// 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. +pub fn evaluate_network(library: &NodeLibrary) -> (Vec, Vec) { let network = &library.root; // Find the rendered child @@ -117,17 +141,42 @@ pub fn evaluate_network(library: &NodeLibrary) -> Vec { Some(name) => name.clone(), None => { // No rendered child, return empty - return Vec::new(); + return (Vec::new(), Vec::new()); } }; // Create a cache for node outputs - let mut cache: HashMap = HashMap::new(); + 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() + let result = evaluate_node(network, &rendered_name, &mut cache); + + match result { + Ok(output) => (output.to_paths(), 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(), vec![NodeError::new(node_name, message)]) + } + } } /// Determine how many times to execute the node for list matching. @@ -222,17 +271,17 @@ fn collect_results(results: Vec) -> NodeOutput { fn evaluate_node( network: &Node, node_name: &str, - cache: &mut HashMap, -) -> NodeOutput { + cache: &mut HashMap, +) -> EvalResult { // Check cache first - if let Some(output) = cache.get(node_name) { - return output.clone(); + 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 NodeOutput::None, + None => return Err(EvalError::NodeNotFound(node_name.to_string())), }; // Collect input values for this node @@ -251,13 +300,13 @@ fn evaluate_node( 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); + 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); + 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)); @@ -275,13 +324,13 @@ fn evaluate_node( .collect(); if all_conns.len() == 1 { - let upstream_output = evaluate_node(network, &conn.output_node, cache); + 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); + 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)); @@ -292,24 +341,28 @@ fn evaluate_node( // 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 + 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) + } 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); + let result = execute_node(node, &iter_inputs)?; results.push(result); } - collect_results(results) + Ok(collect_results(results)) } }; // Cache and return - cache.insert(node_name.to_string(), output.clone()); - output + cache.insert(node_name.to_string(), result.clone()); + result } /// Convert a Value to a NodeOutput. @@ -398,14 +451,40 @@ fn get_string(inputs: &HashMap, name: &str, default: &str) - } } +/// 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) -> NodeOutput { +fn execute_node(node: &Node, inputs: &HashMap) -> EvalResult { // Get the function name (prototype determines what the node does) let proto = match &node.prototype { Some(p) => p.as_str(), - None => return NodeOutput::None, + 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 @@ -414,7 +493,7 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Path(path)) } "corevector.rect" => { let position = get_point(inputs, "position", Point::ZERO); @@ -423,14 +502,14 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput // 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) + 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) as u32; let path = nodebox_ops::line(p1, p2, points); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } "corevector.polygon" => { let position = get_point(inputs, "position", Point::ZERO); @@ -438,7 +517,7 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Path(path)) } "corevector.star" => { let position = get_point(inputs, "position", Point::ZERO); @@ -446,7 +525,7 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Path(path)) } "corevector.arc" => { let position = get_point(inputs, "position", Point::ZERO); @@ -457,79 +536,58 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Path(path)) } // Filters/transforms "corevector.colorize" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::colorize(&shape, fill, stroke, stroke_width); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } "corevector.translate" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + let shape = require_path(inputs, node_name, "shape")?; let offset = get_point(inputs, "translate", Point::ZERO); let path = nodebox_ops::translate(&shape, offset); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } "corevector.rotate" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::rotate(&shape, angle, origin); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } "corevector.scale" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::scale(&shape, scale, origin); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } "corevector.align" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::align_str(&shape, position, &halign, &valign); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } "corevector.fit" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::fit(&shape, position, width, height, keep_proportions); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } "corevector.copy" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + let shape = require_path(inputs, node_name, "shape")?; 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) @@ -537,7 +595,7 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Paths(paths)) } // Combine operations @@ -548,11 +606,11 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput // Try "shape" port as fallback let shape = get_paths(inputs, "shape"); if shape.is_empty() { - return NodeOutput::None; + return Ok(NodeOutput::None); } - return NodeOutput::Paths(shape); + return Ok(NodeOutput::Paths(shape)); } - NodeOutput::Paths(shapes) + Ok(NodeOutput::Paths(shapes)) } // List combine - combines multiple lists into one @@ -564,35 +622,29 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput all_paths.extend(paths); } if all_paths.is_empty() { - NodeOutput::None + Ok(NodeOutput::None) } else { - NodeOutput::Paths(all_paths) + Ok(NodeOutput::Paths(all_paths)) } } // Resample "corevector.resample" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + let shape = require_path(inputs, node_name, "shape")?; let points = get_int(inputs, "points", 20) as usize; let path = nodebox_ops::resample(&shape, points); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } // Wiggle "corevector.wiggle" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + let shape = require_path(inputs, node_name, "shape")?; 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) + Ok(NodeOutput::Path(path)) } // Connect points @@ -602,9 +654,9 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput match inputs.get("points") { Some(NodeOutput::Points(pts)) => { let path = nodebox_ops::connect(pts, closed); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } - _ => NodeOutput::None, + _ => Ok(NodeOutput::None), } } @@ -617,78 +669,63 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput // 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) + 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); - NodeOutput::Point(Point::new(x, y)) + Ok(NodeOutput::Point(Point::new(x, y))) } // Reflect "corevector.reflect" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::reflect(&shape, position, angle, keep_original); - NodeOutput::Paths(nodebox_ops::ungroup(&geometry)) + Ok(NodeOutput::Paths(nodebox_ops::ungroup(&geometry))) } // Skew "corevector.skew" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::skew(&shape, skew, origin); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } // Snap to grid "corevector.snap" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::snap(&shape, distance, strength, position); - NodeOutput::Path(path) + Ok(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 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 = nodebox_ops::point_on_path(&shape, t_normalized); - NodeOutput::Point(point) + Ok(NodeOutput::Point(point)) } // Centroid "corevector.centroid" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + let shape = require_path(inputs, node_name, "shape")?; let point = nodebox_ops::centroid(&shape); - NodeOutput::Point(point) + Ok(NodeOutput::Point(point)) } // Line from angle @@ -698,7 +735,7 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Path(path)) } // Quad curve @@ -708,27 +745,21 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Path(path)) } // Scatter points "corevector.scatter" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + 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 = nodebox_ops::scatter(&shape, amount, seed); - NodeOutput::Points(points) + Ok(NodeOutput::Points(points)) } // Stack "corevector.stack" => { - let shapes = get_paths(inputs, "shapes"); - if shapes.is_empty() { - return NodeOutput::None; - } + 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() { @@ -738,37 +769,31 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput _ => nodebox_ops::StackDirection::East, }; let paths = nodebox_ops::stack(&shapes, dir, margin); - NodeOutput::Paths(paths) + Ok(NodeOutput::Paths(paths)) } // Freehand path "corevector.freehand" => { let path_string = get_string(inputs, "path", ""); let path = nodebox_ops::freehand(&path_string); - NodeOutput::Path(path) + Ok(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 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 = nodebox_ops::link(&shape1, &shape2, horizontal); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } // Group "corevector.group" => { let shapes = get_paths(inputs, "shapes"); let geometry = nodebox_ops::group(&shapes); - NodeOutput::Paths(nodebox_ops::ungroup(&geometry)) + Ok(NodeOutput::Paths(nodebox_ops::ungroup(&geometry))) } // Ungroup @@ -777,35 +802,26 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput let shapes = get_paths(inputs, "geometry"); if shapes.is_empty() { let shape = get_paths(inputs, "shape"); - return NodeOutput::Paths(shape); + return Ok(NodeOutput::Paths(shape)); } - NodeOutput::Paths(shapes) + Ok(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 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 = nodebox_ops::fit_to(&shape, &bounding, keep_proportions); - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } // Delete "corevector.delete" => { - let shape = match get_path(inputs, "shape") { - Some(p) => p, - None => return NodeOutput::None, - }; + let shape = require_path(inputs, node_name, "shape")?; let bounding = match get_path(inputs, "bounding") { Some(p) => p, - None => return NodeOutput::Path(shape), + None => return Ok(NodeOutput::Path(shape)), }; let scope = get_string(inputs, "scope", "points"); let delete_scope = match scope.as_str() { @@ -815,15 +831,12 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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) + Ok(NodeOutput::Path(path)) } // Sort "corevector.sort" => { - let shapes = get_paths(inputs, "shapes"); - if shapes.is_empty() { - return NodeOutput::None; - } + 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" => nodebox_ops::SortBy::Y, @@ -833,19 +846,19 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput }; let position = get_point(inputs, "position", Point::ZERO); let paths = nodebox_ops::sort_paths(&shapes, sort_by, position); - NodeOutput::Paths(paths) + Ok(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) + Ok(NodeOutput::Path(path)) } else if let Some(path) = get_path(inputs, "shapes") { - NodeOutput::Path(path) + Ok(NodeOutput::Path(path)) } else { log::warn!("Unknown node prototype: {}", proto); - NodeOutput::None + Ok(NodeOutput::None) } } } @@ -869,7 +882,7 @@ mod tests { ) .with_rendered_child("ellipse1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -899,7 +912,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "colorize1", "shape")) .with_rendered_child("colorize1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); // Check that the colorize was applied @@ -937,7 +950,7 @@ mod tests { .with_connection(Connection::new("rect1", "merge1", "shapes")) .with_rendered_child("merge1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); // Merge collects all connected shapes assert_eq!(paths.len(), 2); } @@ -955,7 +968,7 @@ mod tests { ) .with_rendered_child("rect1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -976,7 +989,7 @@ mod tests { ) .with_rendered_child("line1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -998,7 +1011,7 @@ mod tests { ) .with_rendered_child("polygon1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); // Hexagon with radius 50 should have bounds approximately 100x86 (2*r x sqrt(3)*r) @@ -1021,7 +1034,7 @@ mod tests { ) .with_rendered_child("star1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); // Star with outer radius 50 should have bounds approximately 100x100 @@ -1045,7 +1058,7 @@ mod tests { ) .with_rendered_child("arc1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); } @@ -1069,7 +1082,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "translate1", "shape")) .with_rendered_child("translate1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1102,7 +1115,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "scale1", "shape")) .with_rendered_child("scale1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1135,7 +1148,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "copy1", "shape")) .with_rendered_child("copy1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); // Should have 3 copies assert_eq!(paths.len(), 3); } @@ -1143,7 +1156,7 @@ mod tests { #[test] fn test_evaluate_empty_network() { let library = NodeLibrary::new("test"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert!(paths.is_empty()); } @@ -1160,7 +1173,7 @@ mod tests { ); // No rendered_child set - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert!(paths.is_empty()); } @@ -1179,7 +1192,7 @@ mod tests { .with_rendered_child("colorize1"); // Should handle missing input gracefully - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert!(paths.is_empty()); } @@ -1194,7 +1207,7 @@ mod tests { .with_rendered_child("unknown1"); // Should handle unknown node type gracefully - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert!(paths.is_empty()); } @@ -1218,7 +1231,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "resample1", "shape")) .with_rendered_child("resample1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); // Resampled path should have the specified number of points // Note: exact point count depends on implementation @@ -1247,7 +1260,7 @@ mod tests { .with_connection(Connection::new("grid1", "connect1", "points")) .with_rendered_child("connect1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); } @@ -1270,7 +1283,7 @@ mod tests { ) .with_rendered_child("ellipse1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1296,7 +1309,7 @@ mod tests { ) .with_rendered_child("rect1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1321,7 +1334,7 @@ mod tests { ) .with_rendered_child("rect1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); // If roundness is applied, the path should have more points than a simple rect } @@ -1341,7 +1354,7 @@ mod tests { ) .with_rendered_child("polygon1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1366,7 +1379,7 @@ mod tests { ) .with_rendered_child("star1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1394,7 +1407,7 @@ mod tests { ) .with_rendered_child("arc1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1429,7 +1442,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "copy1", "shape")) .with_rendered_child("copy1"); - let paths = evaluate_network(&library); + let (paths, _errors) = 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 @@ -1466,7 +1479,7 @@ mod tests { .with_connection(Connection::new("grid1", "connect1", "points")) .with_rendered_child("connect1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1499,7 +1512,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "wiggle1", "shape")) .with_rendered_child("wiggle1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert!(!paths.is_empty(), "Wiggle should produce output"); } @@ -1528,7 +1541,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "fit1", "shape")) .with_rendered_child("fit1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!(paths.len(), 1); // Verify fit produced output - the shape should be constrained to max 50x50 @@ -1611,7 +1624,7 @@ mod tests { .with_connection(Connection::new("polygon1", "combine1", "list3")) .with_rendered_child("combine1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!( paths.len(), @@ -1681,7 +1694,7 @@ mod tests { .with_connection(Connection::new("colorize3", "combine1", "list3")) .with_rendered_child("combine1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!( paths.len(), @@ -1718,7 +1731,7 @@ mod tests { .with_connection(Connection::new("rect1", "colorize1", "shape")) .with_rendered_child("colorize1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); assert_eq!( paths.len(), @@ -1758,7 +1771,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "combine1", "list2")) .with_rendered_child("combine1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); // With no port definitions, list matching treats inputs as VALUE range // Each input is a single path, so iteration count = 1 @@ -1796,7 +1809,7 @@ mod tests { .with_connection(Connection::new("grid1", "rect1", "position")) .with_rendered_child("rect1"); - let paths = evaluate_network(&library); + let (paths, _errors) = evaluate_network(&library); // THE KEY ASSERTION: Must produce 100 rectangles, not 1! assert_eq!( @@ -1806,4 +1819,126 @@ mod tests { 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 (paths, errors) = evaluate_network(&library); + + // 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 (paths, errors) = evaluate_network(&library); + + // 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 (paths, errors) = evaluate_network(&library); + + // 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 (_paths, errors) = evaluate_network(&library); + + 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_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 (paths, errors) = evaluate_network(&library); + + assert!(!paths.is_empty(), "Generator should produce output with defaults"); + assert!(errors.is_empty(), "Generator should not produce errors"); + } } diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index 6b868930..bc1e3340 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -3,7 +3,7 @@ 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 crate::icon_cache::IconCache; use crate::pan_zoom::PanZoom; @@ -99,7 +99,9 @@ impl NetworkView { } /// 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 NodeLibrary, node_errors: &HashMap) -> NetworkAction { let mut action = NetworkAction::None; let (response, painter) = @@ -218,7 +220,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) @@ -725,6 +735,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 +747,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) diff --git a/crates/nodebox-gui/src/render_worker.rs b/crates/nodebox-gui/src/render_worker.rs index d058d0f9..6ffda658 100644 --- a/crates/nodebox-gui/src/render_worker.rs +++ b/crates/nodebox-gui/src/render_worker.rs @@ -4,6 +4,7 @@ use std::sync::mpsc; use std::thread; use nodebox_core::geometry::Path as GeoPath; use nodebox_core::node::NodeLibrary; +use crate::eval::NodeError; /// Unique identifier for a render request. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -20,9 +21,9 @@ pub enum RenderRequest { /// A result returned from the render worker. #[allow(dead_code)] pub enum RenderResult { - /// Evaluation succeeded. - Success { id: RenderRequestId, geometry: Vec }, - /// Evaluation failed. + /// Evaluation completed (may include errors). + Success { id: RenderRequestId, geometry: Vec, errors: Vec }, + /// Evaluation failed completely (e.g., panic in worker). Error { id: RenderRequestId, message: String }, } @@ -137,10 +138,11 @@ fn render_worker_loop( let (final_id, final_library) = drain_to_latest(id, library, &request_rx); // Evaluate the network - let geometry = crate::eval::evaluate_network(&final_library); + let (geometry, errors) = crate::eval::evaluate_network(&final_library); let _ = result_tx.send(RenderResult::Success { id: final_id, geometry, + errors, }); } Ok(RenderRequest::Shutdown) | Err(_) => break, diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index 88c8b9c2..29f57a60 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -1,5 +1,6 @@ //! Application state management. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use nodebox_core::geometry::{Path as GeoPath, Color}; use nodebox_core::node::{Node, NodeLibrary, Port, PortRange}; @@ -27,6 +28,9 @@ pub struct AppState { /// The node library (document). pub library: NodeLibrary, + + /// Per-node error messages (node_name -> error message). + pub node_errors: HashMap, } impl Default for AppState { @@ -42,7 +46,11 @@ impl AppState { let library = Self::create_demo_library(); // Evaluate the network to get the initial geometry - let geometry = eval::evaluate_network(&library); + let (geometry, errors) = eval::evaluate_network(&library); + let node_errors: HashMap = errors + .into_iter() + .map(|e| (e.node_name, e.message)) + .collect(); Self { current_file: None, @@ -52,13 +60,25 @@ impl AppState { selected_node: None, background_color: Color::WHITE, library, + node_errors, } } /// Re-evaluate the network and update the geometry. #[allow(dead_code)] pub fn evaluate(&mut self) { - self.geometry = eval::evaluate_network(&self.library); + let (geometry, errors) = eval::evaluate_network(&self.library); + if errors.is_empty() { + // Success: update geometry and clear errors + self.geometry = geometry; + self.node_errors.clear(); + } else { + // Errors: keep last geometry, populate errors + self.node_errors = errors + .into_iter() + .map(|e| (e.node_name, e.message)) + .collect(); + } } /// Create a demo node library with a single rect node. @@ -88,6 +108,7 @@ impl AppState { self.dirty = false; self.geometry.clear(); self.selected_node = None; + self.node_errors.clear(); } /// Load a file. @@ -105,7 +126,17 @@ impl AppState { self.selected_node = None; // Evaluate the network - self.geometry = eval::evaluate_network(&self.library); + let (geometry, errors) = eval::evaluate_network(&self.library); + if errors.is_empty() { + self.geometry = geometry; + self.node_errors.clear(); + } else { + // Keep previous geometry on error, update error state + self.node_errors = errors + .into_iter() + .map(|e| (e.node_name, e.message)) + .collect(); + } Ok(()) } diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 45224d61..8e92a75f 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -89,7 +89,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; diff --git a/crates/nodebox-gui/tests/file_tests.rs b/crates/nodebox-gui/tests/file_tests.rs index b3cf7d7c..53fbc8e8 100644 --- a/crates/nodebox-gui/tests/file_tests.rs +++ b/crates/nodebox-gui/tests/file_tests.rs @@ -117,17 +117,17 @@ fn test_evaluate_primitives() { test_library.root = library.root.clone(); test_library.root.rendered_child = Some("rect1".to_string()); - let paths = evaluate_network(&test_library); + let (paths, _errors) = 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); + let (paths, _errors) = 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); + let (paths, _errors) = evaluate_network(&test_library); assert_eq!(paths.len(), 1, "polygon1 should produce one path"); } @@ -137,7 +137,7 @@ fn test_evaluate_primitives_full() { // 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); + let (paths, _errors) = evaluate_network(&library); // Should have 3 shapes: rect, ellipse, polygon (each colorized) assert_eq!(paths.len(), 3, "combine1 should produce 3 colorized paths"); @@ -157,7 +157,7 @@ fn test_evaluate_colorized_primitives() { // Test colorized rect (colorize1 <- rect1) test_library.root.rendered_child = Some("colorize1".to_string()); - let paths = evaluate_network(&test_library); + let (paths, _errors) = 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"); @@ -179,7 +179,7 @@ fn test_evaluate_copy() { test_library.root = library.root.clone(); test_library.root.rendered_child = Some(copy.name.clone()); - let paths = evaluate_network(&test_library); + let (paths, _errors) = evaluate_network(&test_library); // Copy should produce multiple paths assert!( !paths.is_empty(), @@ -278,21 +278,21 @@ fn test_primitives_shapes_at_different_positions() { 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); + let (rect_paths, _errors) = 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); + let (ellipse_paths, _errors) = 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); + let (polygon_paths, _errors) = 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; From 75c9ec5404122eaddd3b879d1169098c01dce1b6 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 15:16:16 +0100 Subject: [PATCH 004/100] Add SVG import node with quadratic bezier support - Add native quadratic bezier curve support to geometry system - New QuadTo and QuadData point types - New Quadratic segment variant with De Casteljau algorithm - quad_to() methods on Contour and Path - Implement SVG import using usvg crate - import_svg() function in nodebox-ops - Supports basic shapes, paths, colors, stroke width, transforms - Centering and position offset parameters - Add import_svg node to evaluation system and node library - Add file picker widget to parameter panel for Widget::File - Update all renderers to handle quadratic beziers: - canvas, viewer_pane, export, vello_convert, svg renderer Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 204 ++++++++- crates/nodebox-core/src/geometry/contour.rs | 217 +++++++++- crates/nodebox-core/src/geometry/path.rs | 5 + crates/nodebox-core/src/geometry/point.rs | 33 +- crates/nodebox-gui/src/canvas.rs | 54 +++ crates/nodebox-gui/src/eval.rs | 21 + crates/nodebox-gui/src/export.rs | 24 ++ crates/nodebox-gui/src/node_library.rs | 15 +- crates/nodebox-gui/src/panels.rs | 61 +++ crates/nodebox-gui/src/vello_convert.rs | 27 ++ crates/nodebox-gui/src/viewer_pane.rs | 46 +- crates/nodebox-ops/Cargo.toml | 2 + crates/nodebox-ops/src/lib.rs | 3 + crates/nodebox-ops/src/svg.rs | 447 ++++++++++++++++++++ crates/nodebox-python/src/convert.rs | 2 + crates/nodebox-svg/src/renderer.rs | 25 ++ 16 files changed, 1176 insertions(+), 10 deletions(-) create mode 100644 crates/nodebox-ops/src/svg.rs diff --git a/Cargo.lock b/Cargo.lock index 78afe139..4ab5f098 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" @@ -1092,6 +1098,12 @@ 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" @@ -1611,6 +1623,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" @@ -1669,6 +1687,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" @@ -2394,6 +2435,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" @@ -2556,6 +2603,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" @@ -2947,6 +3005,8 @@ dependencies = [ "nodebox-core", "proptest", "rayon", + "tempfile", + "usvg", ] [[package]] @@ -3395,7 +3455,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]] @@ -3490,7 +3550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2b6aadb221872732e87d465213e9be5af2849b0e8cc5300a8ba98fffa2e00a" dependencies = [ "color", - "kurbo", + "kurbo 0.13.0", "linebender_resource_handle", "smallvec", ] @@ -3545,6 +3605,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" @@ -4165,6 +4231,12 @@ version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +[[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" @@ -4230,6 +4302,22 @@ 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 = "same-file" version = "1.0.6" @@ -4364,6 +4452,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" @@ -4499,6 +4596,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "svg_fmt" @@ -4506,6 +4606,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" @@ -4669,6 +4779,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" @@ -4776,6 +4901,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" @@ -4814,18 +4945,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" @@ -4857,6 +5024,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" @@ -6043,6 +6237,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" 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-gui/src/canvas.rs b/crates/nodebox-gui/src/canvas.rs index 7baf4dab..d1477902 100644 --- a/crates/nodebox-gui/src/canvas.rs +++ b/crates/nodebox-gui/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/eval.rs b/crates/nodebox-gui/src/eval.rs index 684732b1..9f760d83 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -836,6 +836,27 @@ fn execute_node(node: &Node, inputs: &HashMap) -> NodeOutput 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); + + match nodebox_ops::import_svg(&file_path, centered, position) { + Ok(geometry) => { + if geometry.is_empty() { + NodeOutput::None + } else { + NodeOutput::Paths(geometry.paths) + } + } + Err(e) => { + log::warn!("SVG import error: {}", e); + NodeOutput::None + } + } + } + // Default: pass-through or unknown node _ => { // For unknown nodes, try to pass through a shape input diff --git a/crates/nodebox-gui/src/export.rs b/crates/nodebox-gui/src/export.rs index 23d8452e..7db19884 100644 --- a/crates/nodebox-gui/src/export.rs +++ b/crates/nodebox-gui/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/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 0d378039..f4141a47 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -6,7 +6,7 @@ use eframe::egui; use nodebox_core::geometry::{Color, Point}; -use nodebox_core::node::{Node, NodeLibrary, Port, PortRange, PortType}; +use nodebox_core::node::{Node, NodeLibrary, Port, PortRange, PortType, Widget}; /// Available node types that can be created. pub struct NodeTemplate { @@ -119,6 +119,13 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "geometry", description: "Add random displacement to points", }, + // Import nodes + NodeTemplate { + name: "import_svg", + prototype: "corevector.import_svg", + category: "geometry", + description: "Import an SVG file as geometry", + }, ]; /// The node library browser widget. @@ -335,6 +342,12 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::point("offset", Point::new(10.0, 10.0))) .with_input(Port::int("seed", 0)); } + "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)); + } _ => {} } diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index d4bc7dd8..ecb646fb 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -306,6 +306,67 @@ impl ParameterPanel { self.show_drag_value_float(ui, &mut point.y, None, None, 1.0, &key_y, is_editing_y); } } + Widget::File => { + if let Value::String(ref mut path) = port.value { + // Show filename or placeholder + let display_text = if path.is_empty() { + "(none)".to_string() + } else { + // Extract just the filename from the path + std::path::Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()) + }; + + let galley = ui.painter().layout_no_wrap( + display_text, + egui::FontId::proportional(11.0), + if path.is_empty() { theme::TEXT_DISABLED } else { theme::VALUE_TEXT }, + ); + let rect = ui.available_rect_before_wrap(); + let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); + ui.painter().galley(text_pos, galley.clone(), theme::VALUE_TEXT); + + // Add browse button after the text + let button_x = rect.left() + galley.size().x + 8.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(button_x, rect.center().y - 8.0), + egui::vec2(16.0, 16.0), + ); + + let button_response = ui.allocate_rect(button_rect, Sense::click()); + + // Draw folder icon or "..." button + let button_color = if button_response.hovered() { + theme::TEXT_BRIGHT + } else { + theme::TEXT_NORMAL + }; + ui.painter().text( + button_rect.center(), + egui::Align2::CENTER_CENTER, + "…", + egui::FontId::proportional(14.0), + button_color, + ); + + if button_response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + if button_response.clicked() { + // Open file dialog + if let Some(picked_path) = rfd::FileDialog::new() + .add_filter("SVG files", &["svg"]) + .add_filter("All files", &["*"]) + .pick_file() + { + *path = picked_path.to_string_lossy().to_string(); + } + } + } + } _ => { // For geometry and other non-editable types, show type info (non-selectable) let type_str = match port.port_type { diff --git a/crates/nodebox-gui/src/vello_convert.rs b/crates/nodebox-gui/src/vello_convert.rs index 92885d04..d5f470ae 100644 --- a/crates/nodebox-gui/src/vello_convert.rs +++ b/crates/nodebox-gui/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/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index ce2770ce..e312df22 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -811,8 +811,8 @@ impl ViewerPane { // 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, + PointType::CurveTo | PointType::QuadTo => theme::POINT_CURVE_TO, + PointType::CurveData | PointType::QuadData => theme::POINT_CURVE_DATA, }; painter.circle_filled(screen_pt, 3.0, color); } @@ -909,6 +909,36 @@ impl ViewerPane { 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; + } } } @@ -1085,3 +1115,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-ops/Cargo.toml b/crates/nodebox-ops/Cargo.toml index bf26e50c..0706e787 100644 --- a/crates/nodebox-ops/Cargo.toml +++ b/crates/nodebox-ops/Cargo.toml @@ -10,6 +10,7 @@ authors.workspace = true [dependencies] nodebox-core = { path = "../nodebox-core" } rayon = "1.10" +usvg = "0.42" [features] default = [] @@ -18,3 +19,4 @@ parallel = [] [dev-dependencies] proptest = { workspace = true } approx = { workspace = true } +tempfile = "3" diff --git a/crates/nodebox-ops/src/lib.rs b/crates/nodebox-ops/src/lib.rs index 4e5a2d0b..1229c237 100644 --- a/crates/nodebox-ops/src/lib.rs +++ b/crates/nodebox-ops/src/lib.rs @@ -11,6 +11,7 @@ //! - [`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; @@ -18,6 +19,8 @@ pub mod math; pub mod list; pub mod string; pub mod parallel; +pub mod svg; pub use generators::*; pub use filters::*; +pub use svg::import_svg; diff --git a/crates/nodebox-ops/src/svg.rs b/crates/nodebox-ops/src/svg.rs new file mode 100644 index 00000000..dbd67c08 --- /dev/null +++ b/crates/nodebox-ops/src/svg.rs @@ -0,0 +1,447 @@ +//! SVG import functionality. +//! +//! This module provides functions to import SVG files and convert them to NodeBox geometry. + +use nodebox_core::geometry::{Color, Contour, Geometry, Path, Point, Transform}; +use std::path::Path as StdPath; +use usvg::{tiny_skia_path::PathSegment, Tree}; + +/// Import an SVG file and convert it to NodeBox Geometry. +/// +/// # Arguments +/// * `file_path` - Path to the SVG file +/// * `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_ops::import_svg; +/// +/// let geometry = import_svg("shape.svg", true, Point::ZERO)?; +/// ``` +pub fn import_svg(file_path: &str, centered: bool, position: Point) -> Result { + // Empty path returns empty geometry + if file_path.is_empty() { + return Ok(Geometry::new()); + } + + // Check if file exists + let path = StdPath::new(file_path); + if !path.exists() { + return Err(format!("SVG file not found: {}", file_path)); + } + + // Read and parse the SVG file + let svg_data = + std::fs::read(file_path).map_err(|e| format!("Failed to read SVG file: {}", e))?; + + // Parse SVG using usvg with default options + let options = usvg::Options::default(); + let tree = + Tree::from_data(&svg_data, &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_path() { + let result = import_svg("", false, Point::ZERO); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_import_missing_file() { + let result = import_svg("/nonexistent/path/to/file.svg", false, Point::ZERO); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[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_data() { + // Test importing from an in-memory SVG + use std::io::Write; + use tempfile::NamedTempFile; + + let svg_content = r##" + + + + "##; + + // Write to a temp file + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(svg_content.as_bytes()).unwrap(); + + let result = import_svg(temp_file.path().to_str().unwrap(), 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() { + use std::io::Write; + use tempfile::NamedTempFile; + + let svg_content = r##" + + + "##; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(svg_content.as_bytes()).unwrap(); + let path = temp_file.path().to_str().unwrap(); + + // Import without centering + let not_centered = import_svg(path, false, Point::ZERO).unwrap(); + let bounds_not_centered = not_centered.bounds().unwrap(); + + // Import with centering + let centered = import_svg(path, 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() { + use std::io::Write; + use tempfile::NamedTempFile; + + let svg_content = r##" + + + "##; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(svg_content.as_bytes()).unwrap(); + + let offset = Point::new(200.0, 300.0); + let result = import_svg(temp_file.path().to_str().unwrap(), 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() { + use std::io::Write; + use tempfile::NamedTempFile; + + let svg_content = r##" + + + "##; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(svg_content.as_bytes()).unwrap(); + + let result = import_svg(temp_file.path().to_str().unwrap(), 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-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-svg/src/renderer.rs b/crates/nodebox-svg/src/renderer.rs index a90e0e6e..7fd4fea3 100644 --- a/crates/nodebox-svg/src/renderer.rs +++ b/crates/nodebox-svg/src/renderer.rs @@ -322,6 +322,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; + } } } From ab51b27fdd16948039f5b36a090e09ff445dfc2c Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 16:43:16 +0100 Subject: [PATCH 005/100] Add worker-internal cache for render thread Move the node cache from RenderState (main thread) into the worker thread itself to eliminate race conditions that caused "one frame behind" bugs when dragging handles. Key changes: - Remove node_cache from RenderRequest/RenderResult messages - Worker thread maintains persistent cache across render requests - Cache is cleared on each new request (simple, correct behavior) - Simplify app.rs by removing all cache merging logic - Add cancellation token support for cooperative render cancellation - Add stop button to address bar (vertically centered) - Remove status message from address bar Benefits: - No race conditions from cross-thread cache sharing - Simpler code with less state to manage - Only one render runs at a time (natural serialization) - Latest library state always wins via render_pending flag Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 8 + Cargo.lock | 18 ++ crates/nodebox-core/src/node/mod.rs | 4 + crates/nodebox-gui/Cargo.toml | 3 + crates/nodebox-gui/src/address_bar.rs | 146 +++++++++-- crates/nodebox-gui/src/app.rs | 243 +++++++++++++++++- crates/nodebox-gui/src/eval.rs | 243 ++++++++++++++++++ crates/nodebox-gui/src/lib.rs | 2 +- crates/nodebox-gui/src/render_worker.rs | 155 +++++++++-- .../nodebox-gui/tests/cancellation_tests.rs | 241 +++++++++++++++++ docs/async_nodes.md | 231 +++++++++++++++++ 11 files changed, 1232 insertions(+), 62 deletions(-) create mode 100644 crates/nodebox-gui/tests/cancellation_tests.rs create mode 100644 docs/async_nodes.md diff --git a/AGENTS.md b/AGENTS.md index afa59e6f..96a16e2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,14 @@ Prereqs: Java JDK and Apache Ant are required; Maven is used for dependency reso - 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: diff --git a/Cargo.lock b/Cargo.lock index 78afe139..6af725b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2924,6 +2924,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "smol", "tempfile", "tiny-skia", "vello", @@ -4464,6 +4465,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" diff --git a/crates/nodebox-core/src/node/mod.rs b/crates/nodebox-core/src/node/mod.rs index d8f6b562..4011eea3 100644 --- a/crates/nodebox-core/src/node/mod.rs +++ b/crates/nodebox-core/src/node/mod.rs @@ -41,6 +41,9 @@ pub enum EvalError { /// An error occurred during node processing. ProcessingError(String), + /// Evaluation was cancelled by the user. + Cancelled, + /// A general evaluation error. Other(String), } @@ -61,6 +64,7 @@ impl std::fmt::Display for EvalError { } 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-gui/Cargo.toml b/crates/nodebox-gui/Cargo.toml index 49fcd73c..2e65074d 100644 --- a/crates/nodebox-gui/Cargo.toml +++ b/crates/nodebox-gui/Cargo.toml @@ -39,6 +39,9 @@ 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" diff --git a/crates/nodebox-gui/src/address_bar.rs b/crates/nodebox-gui/src/address_bar.rs index 4c9ef64b..7a2a9fd5 100644 --- a/crates/nodebox-gui/src/address_bar.rs +++ b/crates/nodebox-gui/src/address_bar.rs @@ -1,18 +1,34 @@ -//! Address bar with breadcrumb navigation. +//! Address bar with breadcrumb navigation and stop button. #![allow(dead_code)] -use eframe::egui::{self, Sense}; +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, - /// Status message displayed on the right. - message: String, /// 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 { @@ -26,11 +42,18 @@ impl AddressBar { pub fn new() -> Self { Self { segments: vec!["root".to_string()], - message: String::new(), 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 @@ -44,31 +67,22 @@ impl AddressBar { } } - /// 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; + /// 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); - ui.horizontal(|ui| { + // 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 @@ -111,23 +125,99 @@ impl AddressBar { "/{}", self.segments[..=i].join("/") ); - clicked_path = Some(path); + action = AddressBarAction::NavigateTo(path); } } - // Right-aligned status message + // Right-aligned stop button 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), - ); + + // Stop button + if self.draw_stop_button(ui) { + action = AddressBarAction::StopClicked; } }); }); - clicked_path + 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::SLATE_700 + } else if self.render_elapsed_secs < STOP_BUTTON_HIGHLIGHT_THRESHOLD_SECS { + // Rendering but less than threshold: subtle + theme::SLATE_700 + } else { + // Rendering and past threshold: prominent + theme::SLATE_300 + }; + + // 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/app.rs b/crates/nodebox-gui/src/app.rs index 2c1774f5..b4a870fc 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -2,7 +2,7 @@ use eframe::egui::{self, Pos2, Rect, Vec2}; use nodebox_core::geometry::Point; -use crate::address_bar::AddressBar; +use crate::address_bar::{AddressBar, AddressBarAction}; use crate::animation_bar::AnimationBar; use crate::components; use crate::history::History; @@ -265,6 +265,12 @@ impl NodeBoxApp { 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); @@ -277,12 +283,23 @@ impl NodeBoxApp { // 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()); + let (id, cancel_token) = self.render_state.dispatch_new(); + self.render_worker.request_render( + id, + self.state.library.clone(), + cancel_token, + ); self.render_pending = false; } } + /// Cancel the current render operation. + fn cancel_render(&mut self) { + if self.render_state.is_rendering { + self.render_state.cancel(); + } + } + /// 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); @@ -489,13 +506,23 @@ impl eframe::App for NodeBoxApp { .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); + // 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, + ); - if let Some(_clicked_path) = self.address_bar.show(ui) { - // Future: navigate to sub-network + match self.address_bar.show(ui) { + AddressBarAction::NavigateTo(_path) => { + // Future: navigate to sub-network + } + AddressBarAction::StopClicked => { + self.cancel_render(); + } + AddressBarAction::None => {} } }); @@ -605,9 +632,11 @@ impl eframe::App for NodeBoxApp { 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::None => {} } @@ -646,13 +675,19 @@ impl eframe::App for NodeBoxApp { } // Handle keyboard shortcuts - let (do_undo, do_redo) = ctx.input(|i| { + 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)); - (undo, redo) + // 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 { if let Some(previous) = self.history.undo(&self.state.library) { self.state.library = previous; @@ -670,6 +705,11 @@ impl eframe::App for NodeBoxApp { // 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(); + } } } @@ -846,3 +886,184 @@ impl NodeBoxApp { } } } + +#[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 + 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)), + ); + 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::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 + 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)), + ); + 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::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 + 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.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.render_pending = false; + + // Modify the width parameter (simulates what happens when user changes value in panel) + if let Some(node) = 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 + 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.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) = 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" + ); + } +} diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 3971ec62..64cb3193 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -5,6 +5,7 @@ use nodebox_core::geometry::{Path, Point, Color, Contour, PathPoint, PointType}; use nodebox_core::node::{Node, NodeLibrary, EvalError}; use nodebox_core::node::PortRange; use nodebox_core::Value; +use crate::render_worker::CancellationToken; /// Error information for a specific node. #[derive(Debug, Clone)] @@ -28,6 +29,17 @@ impl NodeError { /// 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, + errors: Vec, + }, + /// Evaluation was cancelled before completion. + Cancelled, +} + /// The result of evaluating a node. #[derive(Clone, Debug)] pub enum NodeOutput { @@ -179,6 +191,100 @@ pub fn evaluate_network(library: &NodeLibrary) -> (Vec, Vec) { } } +/// 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). +pub fn evaluate_network_cancellable( + library: &NodeLibrary, + cancel_token: &CancellationToken, + cache: &mut HashMap, +) -> 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(), + 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, + ); + + // 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(), + 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(), + 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( @@ -267,6 +373,143 @@ fn collect_results(results: Vec) -> NodeOutput { } } +/// Evaluate a single node with cancellation support. +fn evaluate_node_cancellable( + network: &Node, + node_name: &str, + cache: &mut HashMap, + cancel_token: &CancellationToken, +) -> 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 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 == 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_cancellable( + network, + &connections[0].output_node, + cache, + cancel_token, + )?; + 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_cancellable( + network, + &conn.output_node, + cache, + cancel_token, + )?; + 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 + 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, + )?; + 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, + )?; + 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) + } + Some(1) => execute_node(node, &inputs), // 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)?; + 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, diff --git a/crates/nodebox-gui/src/lib.rs b/crates/nodebox-gui/src/lib.rs index 6ba49a5d..5ea816be 100644 --- a/crates/nodebox-gui/src/lib.rs +++ b/crates/nodebox-gui/src/lib.rs @@ -34,7 +34,7 @@ mod node_library; mod node_selection_dialog; mod pan_zoom; mod panels; -mod render_worker; +pub mod render_worker; pub mod state; mod theme; mod timeline; diff --git a/crates/nodebox-gui/src/render_worker.rs b/crates/nodebox-gui/src/render_worker.rs index 6ffda658..5cb473a3 100644 --- a/crates/nodebox-gui/src/render_worker.rs +++ b/crates/nodebox-gui/src/render_worker.rs @@ -1,10 +1,49 @@ //! Background render worker for non-blocking network evaluation. -use std::sync::mpsc; +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 crate::eval::NodeError; +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)] @@ -13,7 +52,11 @@ 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 }, + Evaluate { + id: RenderRequestId, + library: NodeLibrary, + cancel_token: CancellationToken, + }, /// Shut down the worker thread. Shutdown, } @@ -22,7 +65,15 @@ pub enum RenderRequest { #[allow(dead_code)] pub enum RenderResult { /// Evaluation completed (may include errors). - Success { id: RenderRequestId, geometry: Vec, errors: Vec }, + Success { + id: RenderRequestId, + geometry: Vec, + errors: Vec, + }, + /// Evaluation was cancelled before completion. + Cancelled { + id: RenderRequestId, + }, /// Evaluation failed completely (e.g., panic in worker). Error { id: RenderRequestId, message: String }, } @@ -33,6 +84,10 @@ pub struct RenderState { 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 { @@ -42,16 +97,23 @@ impl RenderState { 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. - pub fn dispatch_new(&mut self) -> RenderRequestId { + /// 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; - id + 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. @@ -62,6 +124,20 @@ impl RenderState { /// 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()) } } @@ -95,10 +171,19 @@ impl RenderWorkerHandle { } } - /// Request a render of the given library. - pub fn request_render(&self, id: RenderRequestId, library: NodeLibrary) { + /// Request a render of the given library with cancellation support. + pub fn request_render( + &self, + id: RenderRequestId, + library: NodeLibrary, + cancel_token: CancellationToken, + ) { if let Some(ref tx) = self.request_tx { - let _ = tx.send(RenderRequest::Evaluate { id, library }); + let _ = tx.send(RenderRequest::Evaluate { + id, + library, + cancel_token, + }); } } @@ -131,19 +216,42 @@ 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 }) => { + Ok(RenderRequest::Evaluate { id, library, cancel_token }) => { // 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, errors) = crate::eval::evaluate_network(&final_library); - let _ = result_tx.send(RenderResult::Success { - id: final_id, - geometry, - errors, - }); + let (final_id, final_library, final_token) = + drain_to_latest(id, library, cancel_token, &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, + ); + + match result { + crate::eval::EvalOutcome::Completed { geometry, errors } => { + let _ = result_tx.send(RenderResult::Success { + id: final_id, + geometry, + errors, + }); + } + crate::eval::EvalOutcome::Cancelled => { + let _ = result_tx.send(RenderResult::Cancelled { + id: final_id, + }); + } + } } Ok(RenderRequest::Shutdown) | Err(_) => break, } @@ -154,19 +262,22 @@ fn render_worker_loop( fn drain_to_latest( mut id: RenderRequestId, mut library: NodeLibrary, + mut cancel_token: CancellationToken, rx: &mpsc::Receiver, -) -> (RenderRequestId, NodeLibrary) { +) -> (RenderRequestId, NodeLibrary, CancellationToken) { while let Ok(req) = rx.try_recv() { match req { RenderRequest::Evaluate { id: new_id, library: new_lib, + cancel_token: new_token, } => { id = new_id; library = new_lib; + cancel_token = new_token; } RenderRequest::Shutdown => break, } } - (id, library) + (id, library, cancel_token) } diff --git a/crates/nodebox-gui/tests/cancellation_tests.rs b/crates/nodebox-gui/tests/cancellation_tests.rs new file mode 100644 index 00000000..fc7981af --- /dev/null +++ b/crates/nodebox-gui/tests/cancellation_tests.rs @@ -0,0 +1,241 @@ +//! Integration tests for render cancellation. + +use std::collections::HashMap; +use std::thread; +use std::time::{Duration, Instant}; +use nodebox_core::geometry::Point; +use nodebox_core::node::{Node, NodeLibrary, Port}; +use nodebox_gui::eval::{EvalOutcome, NodeOutput, evaluate_network_cancellable}; +use nodebox_gui::render_worker::CancellationToken; + +/// 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 outcome = evaluate_network_cancellable(&library, &token, &mut cache); + + 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 outcome = evaluate_network_cancellable(&library, &token, &mut cache); + + 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 outcome = evaluate_network_cancellable(&library, &token, &mut cache); + + // 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 outcome1 = evaluate_network_cancellable(&library, &token1, &mut cache); + + 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); + + 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(); + evaluate_network_cancellable(&library_clone, &token_for_thread, &mut thread_cache) + }); + + // 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 outcome = evaluate_network_cancellable(&library, &token, &mut cache); + + // 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 outcome = evaluate_network_cancellable(&library, &token, &mut cache); + + // 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/docs/async_nodes.md b/docs/async_nodes.md new file mode 100644 index 00000000..5b17d463 --- /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-gui/src/render_worker.rs` - CancellationToken, worker loop +- `crates/nodebox-gui/src/eval.rs` - evaluate_network_cancellable() +- `crates/nodebox-gui/src/address_bar.rs` - Stop button UI +- `crates/nodebox-gui/tests/cancellation_tests.rs` - Integration tests From f4e6ee812021c4c2cca806cfbe7d8a15d442713c Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 16:44:22 +0100 Subject: [PATCH 006/100] Implement .ndbx file saving with XML serialization Add proper .ndbx file saving support to the nodebox-ndbx crate: - Add serializer module with serialize() and serialize_to_file() functions - Add upgrades module with version upgrade support (v21 -> v22) - Define CURRENT_FORMAT_VERSION (22) and MIN_SUPPORTED_VERSION (21) - Integrate upgrade call into parse_file() for automatic version migration - Update save_file() in state.rs to use the new serializer The Rust implementation uses format version 22, and can load files from Java's version 21. Older versions (< 21) are rejected with an error. Also updates tests to reflect the new version requirements: - Tests now expect old v17 example files to be rejected - Evaluation tests use programmatically created libraries - Library files (no version) default to v21 and upgrade to v22 Co-Authored-By: Claude Opus 4.5 --- crates/nodebox-gui/src/state.rs | 3 +- crates/nodebox-gui/tests/file_tests.rs | 477 +++++++++--------- crates/nodebox-gui/tests/handle_tests.rs | 43 +- crates/nodebox-ndbx/src/lib.rs | 15 +- crates/nodebox-ndbx/src/parser.rs | 7 +- crates/nodebox-ndbx/src/serializer.rs | 533 ++++++++++++++++++++ crates/nodebox-ndbx/src/upgrades.rs | 74 +++ crates/nodebox-ndbx/tests/parse_examples.rs | 57 +-- 8 files changed, 929 insertions(+), 280 deletions(-) create mode 100644 crates/nodebox-ndbx/src/serializer.rs create mode 100644 crates/nodebox-ndbx/src/upgrades.rs diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index 29f57a60..4e607d8b 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -143,7 +143,8 @@ impl AppState { /// Save the current document. pub fn save_file(&mut self, path: &Path) -> Result<(), String> { - // TODO: Implement proper .ndbx saving + nodebox_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(()) diff --git a/crates/nodebox-gui/tests/file_tests.rs b/crates/nodebox-gui/tests/file_tests.rs index 53fbc8e8..887757fd 100644 --- a/crates/nodebox-gui/tests/file_tests.rs +++ b/crates/nodebox-gui/tests/file_tests.rs @@ -1,120 +1,166 @@ -//! Tests for loading and evaluating .ndbx files from the examples directory. +//! Tests for loading and evaluating .ndbx files. +//! +//! Note: The Rust implementation only supports .ndbx format version 21+. +//! Older example files (version 17) from the Java implementation are rejected. +//! These tests verify that behavior and test evaluation with programmatically +//! created libraries. use std::path::PathBuf; +use nodebox_core::geometry::{Color, Point}; +use nodebox_core::node::{Connection, Node, NodeLibrary, Port}; use nodebox_gui::eval::evaluate_network; use nodebox_gui::{populate_default_ports, AppState}; +use nodebox_ndbx::NdbxError; /// 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") + 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 +/// 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") } // ============================================================================ -// 01 Basics / 01 Shape +// Version compatibility tests // ============================================================================ #[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()); -} +fn test_old_version_files_are_rejected() { + // The Primitives example has formatVersion="17" which is below our minimum (21) + 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; + } -#[test] -fn test_load_lines() { - let library = load_example("01 Basics/01 Shape/02 Lines/02 Lines.ndbx"); + let result = nodebox_ndbx::parse_file(&path); + assert!(result.is_err(), "Old version files should be rejected"); - assert_eq!(library.root.name, "root"); - assert!(!library.root.children.is_empty()); + match result.unwrap_err() { + NdbxError::UnsupportedVersion(v) => { + assert_eq!(v, 17, "Expected version 17 rejection"); + } + other => panic!("Expected UnsupportedVersion error, got: {:?}", other), + } } #[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"); -} +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; + } -#[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"); -} + let library = nodebox_ndbx::parse_file(&path).expect("Library files should load"); -#[test] -fn test_load_transformations() { - let library = load_example("01 Basics/01 Shape/06 Transformations/06 Transformations.ndbx"); + // After upgrade, format version should be 22 + assert_eq!(library.format_version, 22); - assert_eq!(library.root.name, "root"); - assert!(!library.root.children.is_empty()); + // 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 - verify we can evaluate loaded files +// 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 = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); + let library = create_primitives_library(); // 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(); + let mut test_library = library.clone(); test_library.root.rendered_child = Some("rect1".to_string()); let (paths, _errors) = evaluate_network(&test_library); @@ -133,10 +179,9 @@ fn test_evaluate_primitives() { #[test] fn test_evaluate_primitives_full() { - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); + let library = create_primitives_library(); // The rendered child is "combine1" which uses list.combine - // Now that list.combine is implemented, we can evaluate the full network let (paths, _errors) = evaluate_network(&library); // Should have 3 shapes: rect, ellipse, polygon (each colorized) @@ -150,10 +195,9 @@ fn test_evaluate_primitives_full() { #[test] fn test_evaluate_colorized_primitives() { - let library = load_example("01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); + let library = create_primitives_library(); - let mut test_library = nodebox_core::node::NodeLibrary::new("test"); - test_library.root = library.root.clone(); + let mut test_library = library.clone(); // Test colorized rect (colorize1 <- rect1) test_library.root.rendered_child = Some("colorize1".to_string()); @@ -163,120 +207,17 @@ fn test_evaluate_colorized_primitives() { 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, _errors) = 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"); + // 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 = nodebox_core::node::NodeLibrary::new("test"); - test_library.root = library.root.clone(); + let mut test_library = library.clone(); test_library.root.rendered_child = Some("rect1".to_string()); let (rect_paths, _errors) = evaluate_network(&test_library); assert_eq!(rect_paths.len(), 1, "rect1 should produce one path"); @@ -297,7 +238,7 @@ fn test_primitives_shapes_at_different_positions() { 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 + // 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, @@ -332,14 +273,14 @@ fn test_primitives_shapes_at_different_positions() { #[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"); + // 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 after loading" + "rect1 should have a 'position' port" ); if let Some(port) = position_port { match &port.value { @@ -358,48 +299,134 @@ fn test_position_port_is_point_type() { let position_port = ellipse.input("position"); assert!( position_port.is_some(), - "ellipse1 should have a 'position' port after loading" + "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 after loading" + "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() { + let mut state = AppState::new(); + + // Try to load an old version file - should fail + 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_err(), + "load_file should fail for old version files" + ); +} + +#[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" ); } // ============================================================================ -// Bulk loading test - verify all example files can be parsed +// Round-trip serialization test // ============================================================================ #[test] -fn test_load_all_example_files() { - let examples = examples_dir(); - if !examples.exists() { - println!("Examples directory not found, skipping test"); +fn test_save_and_reload() { + // Create a library + let original = create_primitives_library(); + + // Serialize to string + let xml = nodebox_ndbx::serialize(&original); + + // Parse back + let reloaded = nodebox_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 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 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_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())); + } + } } } } @@ -407,9 +434,7 @@ fn test_load_all_example_files() { } } - walk_dir(&examples, &mut loaded, &mut failed); - - println!("Loaded {} example files", loaded); + println!("Loaded {} library files", loaded); if !failed.is_empty() { println!("Failed to load {} files:", failed.len()); @@ -418,7 +443,5 @@ fn test_load_all_example_files() { } } - 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 + assert!(loaded > 0, "Should have loaded at least one library file"); } diff --git a/crates/nodebox-gui/tests/handle_tests.rs b/crates/nodebox-gui/tests/handle_tests.rs index d99fbeef..0572c52a 100644 --- a/crates/nodebox-gui/tests/handle_tests.rs +++ b/crates/nodebox-gui/tests/handle_tests.rs @@ -248,16 +248,35 @@ 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::{Connection, 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"); + + 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); - // 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); // Get nodes @@ -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-ndbx/src/lib.rs b/crates/nodebox-ndbx/src/lib.rs index 163f690d..0fdb41f7 100644 --- a/crates/nodebox-ndbx/src/lib.rs +++ b/crates/nodebox-ndbx/src/lib.rs @@ -1,19 +1,26 @@ -//! NDBX file format parser for NodeBox. +//! NDBX file format parser and serializer for NodeBox. //! //! This crate parses `.ndbx` files (XML-based) into NodeBox's internal -//! node graph representation. +//! node graph representation, and serializes them back to XML. //! //! # Example //! //! ```no_run -//! use nodebox_ndbx::parse_file; +//! use nodebox_ndbx::{parse_file, serialize_to_file}; //! //! let library = parse_file("examples/my_project.ndbx").unwrap(); //! println!("Loaded library: {}", library.name); +//! +//! // Save the library back to a file +//! serialize_to_file(&library, "output.ndbx").unwrap(); //! ``` -mod parser; mod error; +mod parser; +mod serializer; +mod upgrades; pub use error::{NdbxError, Result}; pub use parser::{parse, parse_file}; +pub use serializer::{serialize, serialize_to_file}; +pub use upgrades::{upgrade, CURRENT_FORMAT_VERSION, MIN_SUPPORTED_VERSION}; diff --git a/crates/nodebox-ndbx/src/parser.rs b/crates/nodebox-ndbx/src/parser.rs index ee4fa8f3..b54b08f1 100644 --- a/crates/nodebox-ndbx/src/parser.rs +++ b/crates/nodebox-ndbx/src/parser.rs @@ -11,11 +11,16 @@ use nodebox_core::geometry::Point; use nodebox_core::Value; use crate::error::{NdbxError, Result}; +use crate::upgrades::upgrade; /// Parses an NDBX file from the given path. +/// +/// After parsing, the library is automatically upgraded to the current format version. pub fn parse_file(path: impl AsRef) -> Result { let content = fs::read_to_string(path)?; - parse(&content) + let mut library = parse(&content)?; + upgrade(&mut library)?; + Ok(library) } /// Parses NDBX content from a string. diff --git a/crates/nodebox-ndbx/src/serializer.rs b/crates/nodebox-ndbx/src/serializer.rs new file mode 100644 index 00000000..6a7315c5 --- /dev/null +++ b/crates/nodebox-ndbx/src/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 nodebox_core::node::{Connection, MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; +use nodebox_core::Value; + +use crate::error::Result; +use crate::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 nodebox_core::geometry::{Color, Point}; + use nodebox_core::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::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-ndbx/src/upgrades.rs b/crates/nodebox-ndbx/src/upgrades.rs new file mode 100644 index 00000000..9feed241 --- /dev/null +++ b/crates/nodebox-ndbx/src/upgrades.rs @@ -0,0 +1,74 @@ +//! Version upgrades for .ndbx files. +//! +//! This module handles upgrading older .ndbx file formats to the current version. +//! Currently supports upgrading from Java's version 21 to Rust's version 22. + +use nodebox_core::node::NodeLibrary; + +use crate::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 (Java's latest). +pub const MIN_SUPPORTED_VERSION: u32 = 21; + +/// Upgrades a NodeLibrary to the current format version. +/// +/// # Errors +/// +/// Returns an error if the format version is too old (< 21) or too new (> 22). +pub fn upgrade(library: &mut NodeLibrary) -> Result<(), NdbxError> { + match library.format_version { + v if v < MIN_SUPPORTED_VERSION => Err(NdbxError::UnsupportedVersion(v)), + 21 => { + // Upgrade from Java version 21 to Rust version 22 + // Currently a no-op (format is compatible) + library.format_version = CURRENT_FORMAT_VERSION; + Ok(()) + } + 22 => Ok(()), // 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; + + upgrade(&mut library).unwrap(); + assert_eq!(library.format_version, 22); + } + + #[test] + fn test_upgrade_v22_no_change() { + let mut library = NodeLibrary::default(); + library.format_version = 22; + + upgrade(&mut library).unwrap(); + assert_eq!(library.format_version, 22); + } + + #[test] + fn test_upgrade_old_version_error() { + let mut library = NodeLibrary::default(); + library.format_version = 20; + + let result = upgrade(&mut library); + assert!(result.is_err()); + } + + #[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-ndbx/tests/parse_examples.rs b/crates/nodebox-ndbx/tests/parse_examples.rs index 3536a646..ecaff5d3 100644 --- a/crates/nodebox-ndbx/tests/parse_examples.rs +++ b/crates/nodebox-ndbx/tests/parse_examples.rs @@ -1,11 +1,11 @@ //! Integration tests for parsing actual NDBX files. -use nodebox_ndbx::parse_file; +use nodebox_ndbx::{parse_file, NdbxError, MIN_SUPPORTED_VERSION}; use std::path::Path; -/// Test parsing the Primitives example. +/// Test that parsing old format versions (< 21) returns an error. #[test] -fn test_parse_primitives_example() { +fn test_parse_old_version_returns_error() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../examples/01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); @@ -14,36 +14,21 @@ fn test_parse_primitives_example() { return; } - let library = parse_file(&path).expect("Failed to parse Primitives.ndbx"); + // This file has formatVersion="17" which is below our minimum supported version + let result = parse_file(&path); + assert!(result.is_err(), "Expected error for old format version"); - assert_eq!(library.format_version, 17); - assert!(library.uuid.is_some()); - assert_eq!(library.width(), 1000.0); - assert_eq!(library.height(), 1000.0); - - // Root should be a network with "combine1" as rendered child - assert_eq!(library.root.name, "root"); - assert_eq!(library.root.rendered_child, Some("combine1".to_string())); - - // Should have several child nodes - assert!(!library.root.children.is_empty()); - - // Check some specific nodes exist - let has_rect = library.root.children.iter().any(|n| n.name == "rect1"); - let has_ellipse = library.root.children.iter().any(|n| n.name == "ellipse1"); - let has_polygon = library.root.children.iter().any(|n| n.name == "polygon1"); - let has_combine = library.root.children.iter().any(|n| n.name == "combine1"); - - assert!(has_rect, "Missing rect1 node"); - assert!(has_ellipse, "Missing ellipse1 node"); - assert!(has_polygon, "Missing polygon1 node"); - assert!(has_combine, "Missing combine1 node"); - - // Check connections exist - assert!(!library.root.connections.is_empty()); + match result.unwrap_err() { + NdbxError::UnsupportedVersion(v) => { + assert!(v < MIN_SUPPORTED_VERSION, "Expected version < 21, got {}", v); + } + other => panic!("Expected UnsupportedVersion error, got: {:?}", other), + } } /// Test parsing the corevector library. +/// Library files have no formatVersion attribute, which defaults to 21. +/// After loading, the library is upgraded to version 22. #[test] fn test_parse_corevector_library() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -56,6 +41,9 @@ fn test_parse_corevector_library() { let library = parse_file(&path).expect("Failed to parse corevector.ndbx"); + // After parsing and upgrading, format version should be 22 + assert_eq!(library.format_version, 22, "Expected upgraded format version"); + // Check that key nodes exist let child_names: Vec<&str> = library.root.children.iter().map(|n| n.name.as_str()).collect(); @@ -73,9 +61,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) returns an error. #[test] -fn test_parse_demo_file() { +fn test_parse_old_demo_file_returns_error() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../src/test/files/demo.ndbx"); @@ -84,8 +72,7 @@ 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 + let result = parse_file(&path); + assert!(result.is_err(), "Expected error for old format version"); } From 28b8ed3f99c8dbe3a95c2e58afc317bcc249c788 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 16:52:04 +0100 Subject: [PATCH 007/100] Clarify branching strategy and active development in AGENTS.md. Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 96a16e2d..a3b99015 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,13 @@ 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. From 35a786a6cfe5039c4b798c302a5f18af9b67841b Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 16:53:08 +0100 Subject: [PATCH 008/100] Also allow GitHub PR commands --- .claude/settings.json | 1 + 1 file changed, 1 insertion(+) 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 *)", From 01457fd64f8494388a2904b460b9948886ead6e1 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 19:42:00 +0100 Subject: [PATCH 009/100] Fix menu widgets not showing for arc and other nodes When creating new nodes via the node selection dialog, menu ports (arc type, copy order, resample method, wiggle scope) were displayed as plain text inputs instead of dropdown menus. Changed Port::string() to Port::menu() with proper MenuItem definitions for arc, copy, resample, and wiggle nodes in create_node_from_template(). Co-Authored-By: Claude Opus 4.5 --- crates/nodebox-gui/src/node_library.rs | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index bf66c6c0..3943f1a6 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -6,7 +6,7 @@ use eframe::egui; use nodebox_core::geometry::{Color, Point}; -use nodebox_core::node::{Node, NodeLibrary, Port, PortRange, PortType, Widget}; +use nodebox_core::node::{MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; /// Available node types that can be created. pub struct NodeTemplate { @@ -285,7 +285,11 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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")); + .with_input(Port::menu("type", "pie", vec![ + MenuItem::new("pie", "Pie"), + MenuItem::new("chord", "Chord"), + MenuItem::new("open", "Open"), + ])); } "quad_curve" => { node = node @@ -325,7 +329,14 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, node = node .with_input(Port::geometry("shape")) .with_input(Port::int("copies", 1)) - .with_input(Port::string("order", "tsr")) + .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))); @@ -343,7 +354,10 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, "resample" => { node = node .with_input(Port::geometry("shape")) - .with_input(Port::string("method", "length")) + .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_input(Port::int("points", 10)) .with_input(Port::boolean("per_contour", false)); @@ -351,7 +365,11 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, "wiggle" => { node = node .with_input(Port::geometry("shape")) - .with_input(Port::string("scope", "points")) + .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)); } From 0c385f33c0217b83e2a2c6c941d525f7520683db Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 19:55:54 +0100 Subject: [PATCH 010/100] Add menu/dropdown widgets for node parameters Replace plain text inputs with proper dropdown menus for parameters with discrete options. This improves usability by showing available choices instead of requiring users to type exact values. Core changes: - Add Port::with_menu_items() and Port::menu() helper methods - Render Widget::Menu using egui's ComboBox - Convert string parameters to menus for nodes: arc, copy, align, resample, wiggle, stack, sort, compound, link, textpath, delete, distribute, shape_on_path, text_on_path - Update ensure_port() to migrate existing String widgets to Menu widgets Theme fixes: - Set weak_bg_fill for all widget states (fixes brownish-gray colors) - Add VIOLET_800 for better selection contrast - Sharp menu corners, no popup shadows, compact button padding Documentation: - Add egui styling documentation to STYLE_GUIDE.md Co-Authored-By: Claude Opus 4.5 --- STYLE_GUIDE.md | 140 +++++++++++++++++++++++ crates/nodebox-core/src/node/port.rs | 16 +++ crates/nodebox-gui/src/panels.rs | 26 +++++ crates/nodebox-gui/src/state.rs | 160 +++++++++++++++++++++++++-- crates/nodebox-gui/src/theme.rs | 47 +++++--- 5 files changed, 360 insertions(+), 29 deletions(-) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index de2e9309..db34d9ee 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -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-core/src/node/port.rs b/crates/nodebox-core/src/node/port.rs index 10ccd80b..42b5277c 100644 --- a/crates/nodebox-core/src/node/port.rs +++ b/crates/nodebox-core/src/node/port.rs @@ -338,6 +338,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)] diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index ecb646fb..c1c379ae 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -306,6 +306,32 @@ impl ParameterPanel { self.show_drag_value_float(ui, &mut point.y, None, None, 1.0, &key_y, is_editing_y); } } + 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 diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index 4e607d8b..e5d5c9db 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use nodebox_core::geometry::{Path as GeoPath, Color}; -use nodebox_core::node::{Node, NodeLibrary, Port, PortRange}; +use nodebox_core::node::{Node, NodeLibrary, MenuItem, Port, PortRange, Widget}; use crate::eval; /// The main application state. @@ -210,7 +210,11 @@ pub fn populate_default_ports(node: &mut Node) { 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")); + ensure_port(node, "type", || Port::menu("type", "pie", vec![ + MenuItem::new("pie", "Pie"), + MenuItem::new("chord", "Chord"), + MenuItem::new("open", "Open"), + ])); } // Filters "corevector.colorize" => { @@ -236,7 +240,14 @@ pub fn populate_default_ports(node: &mut Node) { "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, "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))); @@ -244,8 +255,18 @@ pub fn populate_default_ports(node: &mut Node) { "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")); + 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")); @@ -256,11 +277,21 @@ pub fn populate_default_ports(node: &mut Node) { } "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::string("scope", "points")); + 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)); } @@ -274,12 +305,23 @@ pub fn populate_default_ports(node: &mut Node) { } "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)); + 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::string("order_by", "x")); + 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" => { @@ -315,17 +357,113 @@ pub fn populate_default_ports(node: &mut Node) { 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)); + } _ => {} } } } -/// Ensure a port exists on a node, adding it with the default if missing. +/// 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 node.input(name).is_none() { + 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-gui/src/theme.rs index 8e92a75f..42bcf2d6 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -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 @@ -112,8 +114,8 @@ pub const SURFACE_ELEVATED: Color32 = SLATE_700; pub const TEXT_EDIT_BG: Color32 = SLATE_700; /// Hover state background pub const HOVER_BG: Color32 = SLATE_600; -/// Selection background (subtle violet) -pub const SELECTION_BG: Color32 = VIOLET_900; +/// Selection background (visible violet with good text contrast) +pub const SELECTION_BG: Color32 = VIOLET_800; // ============================================================================= // SEMANTIC COLORS - Text @@ -133,9 +135,9 @@ pub const TEXT_DISABLED: Color32 = SLATE_500; // ============================================================================= /// Widget inactive background -pub const WIDGET_INACTIVE_BG: Color32 = SLATE_600; +pub const WIDGET_INACTIVE_BG: Color32 = SLATE_700; /// Widget hovered background -pub const WIDGET_HOVERED_BG: Color32 = SLATE_500; +pub const WIDGET_HOVERED_BG: Color32 = SLATE_600; /// Widget active/pressed background pub const WIDGET_ACTIVE_BG: Color32 = SLATE_400; /// Non-interactive widget background @@ -373,10 +375,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, @@ -391,41 +393,50 @@ pub fn configure_style(ctx: &egui::Context) { 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 - Widgets (sharp corners, minimal borders) - visuals.widgets.noninteractive.bg_fill = WIDGET_NONINTERACTIVE_BG; + visuals.widgets.noninteractive.bg_fill = SLATE_800; + visuals.widgets.noninteractive.weak_bg_fill = SLATE_800; 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 = 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; - visuals.widgets.hovered.bg_fill = WIDGET_HOVERED_BG; - visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT_STRONG); + 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 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 = SLATE_600; + visuals.widgets.active.weak_bg_fill = SLATE_600; + visuals.widgets.active.fg_stroke = Stroke::new(1.0, SLATE_100); 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 = SLATE_600; + visuals.widgets.open.weak_bg_fill = SLATE_600; + visuals.widgets.open.fg_stroke = Stroke::new(1.0, SLATE_200); 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; From ad29bfda156ef6f8c576e5ba2da70328febaabbb Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 21:40:46 +0100 Subject: [PATCH 011/100] Add zoom controls to viewer pane with snap-to-level behavior - Add +/- buttons and percentage display in viewer header - Implement predefined zoom levels (1% to 1000%) - Zoom buttons snap to nearest predefined level instead of linear scaling Co-Authored-By: Claude Opus 4.5 --- crates/nodebox-gui/src/components.rs | 131 ++++++++++++++++++++++++++ crates/nodebox-gui/src/pan_zoom.rs | 22 ++++- crates/nodebox-gui/src/viewer_pane.rs | 33 +++++-- 3 files changed, 175 insertions(+), 11 deletions(-) diff --git a/crates/nodebox-gui/src/components.rs b/crates/nodebox-gui/src/components.rs index 1cfebe77..445caedb 100644 --- a/crates/nodebox-gui/src/components.rs +++ b/crates/nodebox-gui/src/components.rs @@ -337,3 +337,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::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.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_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_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_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::SLATE_700, + ); + } + + // 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-gui/src/pan_zoom.rs b/crates/nodebox-gui/src/pan_zoom.rs index f101d132..e0d3245e 100644 --- a/crates/nodebox-gui/src/pan_zoom.rs +++ b/crates/nodebox-gui/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, } } @@ -97,14 +103,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-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index e312df22..11471541 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -256,11 +256,6 @@ impl ViewerPane { } } - /// 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 { @@ -423,7 +418,7 @@ impl ViewerPane { } x = new_x; - let (clicked, _) = components::header_tab_button( + let (clicked, new_x) = components::header_tab_button( ui, header_rect, x, @@ -435,6 +430,32 @@ impl ViewerPane { } else if clicked { self.current_tab = ViewerTab::Viewer; } + x = new_x; + + // 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 { From aca95b9af99728199c35dc7ea7690176f510f6e1 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 21:47:12 +0100 Subject: [PATCH 012/100] Add Port trait for sandboxed file access Introduces nodebox-port crate with platform abstraction: - Port trait for sandboxed file I/O operations - ProjectContext for tracking project save state - DesktopPort implementation with sandbox validation - TestPort for testing without filesystem access Updates nodebox-gui to use Port throughout: - evaluate_network() now takes port and project_context - import_svg reads files through Port instead of direct access - Removed TestPort from production code (critical fix) - AppState defers evaluation to render worker Build changes: - Root Cargo.toml now builds main binary directly - Updated build scripts for nodebox package name Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 4 +- Cargo.lock | 116 +- Cargo.toml | 9 +- crates/nodebox-gui/Cargo.toml | 4 +- crates/nodebox-gui/src/app.rs | 258 +++- crates/nodebox-gui/src/eval.rs | 587 +++++---- crates/nodebox-gui/src/lib.rs | 14 +- crates/nodebox-gui/src/panels.rs | 73 +- crates/nodebox-gui/src/render_worker.rs | 25 +- crates/nodebox-gui/src/state.rs | 51 +- .../nodebox-gui/tests/cancellation_tests.rs | 31 +- crates/nodebox-gui/tests/file_tests.rs | 39 +- crates/nodebox-ops/src/svg.rs | 84 +- crates/nodebox-port/Cargo.toml | 38 + crates/nodebox-port/src/desktop.rs | 851 +++++++++++++ crates/nodebox-port/src/lib.rs | 1096 +++++++++++++++++ scripts/build-linux-appimage.sh | 4 +- scripts/build-mac-bundle.sh | 2 +- scripts/build-windows-installer.ps1 | 6 +- 19 files changed, 2837 insertions(+), 455 deletions(-) create mode 100644 crates/nodebox-port/Cargo.toml create mode 100644 crates/nodebox-port/src/desktop.rs create mode 100644 crates/nodebox-port/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index a3b99015..6affc32e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,9 +262,9 @@ 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 # Run the desktop GUI application cargo run -p nodebox-cli # Run the CLI cargo test -p nodebox-core # Test specific crate ``` diff --git a/Cargo.lock b/Cargo.lock index d64f1177..b2d6b61e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2977,9 +2977,9 @@ dependencies = [ "nodebox-core", "nodebox-ndbx", "nodebox-ops", + "nodebox-port", "nodebox-svg", "pollster", - "rfd", "serde", "serde_json", "smol", @@ -3010,6 +3010,19 @@ dependencies = [ "usvg", ] +[[package]] +name = "nodebox-port" +version = "0.1.0" +dependencies = [ + "arboard", + "directories", + "log", + "rfd", + "tempfile", + "thiserror 1.0.69", + "ureq", +] + [[package]] name = "nodebox-python" version = "0.1.0" @@ -4232,6 +4245,20 @@ 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" @@ -4285,6 +4312,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" @@ -4618,6 +4680,12 @@ 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" version = "0.4.5" @@ -5023,6 +5091,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" @@ -5425,6 +5515,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" @@ -6439,6 +6547,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" diff --git a/Cargo.toml b/Cargo.toml index fe51d41e..70a2d74f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ name = "nodebox" version.workspace = true edition.workspace = true - default-run = "NodeBox" [[bin]] @@ -16,18 +15,19 @@ 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-port", "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", @@ -36,6 +36,7 @@ default-members = [ "crates/nodebox-svg", "crates/nodebox-cli", "crates/nodebox-gui", + "crates/nodebox-port", ] [workspace.package] diff --git a/crates/nodebox-gui/Cargo.toml b/crates/nodebox-gui/Cargo.toml index 2e65074d..476b937a 100644 --- a/crates/nodebox-gui/Cargo.toml +++ b/crates/nodebox-gui/Cargo.toml @@ -15,6 +15,7 @@ path = "src/lib.rs" nodebox-core = { path = "../nodebox-core" } nodebox-ops = { path = "../nodebox-ops" } nodebox-ndbx = { path = "../nodebox-ndbx" } +nodebox-port = { path = "../nodebox-port" } nodebox-svg = { path = "../nodebox-svg" } # GUI @@ -23,9 +24,6 @@ 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"] } diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index b4a870fc..51a4711e 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -2,6 +2,9 @@ use eframe::egui::{self, Pos2, Rect, Vec2}; use nodebox_core::geometry::Point; +use nodebox_port::{Port, ProjectContext}; +use std::sync::Arc; + use crate::address_bar::{AddressBar, AddressBarAction}; use crate::animation_bar::AnimationBar; use crate::components; @@ -19,6 +22,10 @@ use crate::viewer_pane::{HandleResult, ViewerPane}; /// The main NodeBox application. pub struct NodeBoxApp { + /// Port 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, @@ -44,13 +51,87 @@ pub struct NodeBoxApp { } impl NodeBoxApp { - /// Create a new NodeBox application instance. + /// Create a new NodeBox application instance with a Port 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); + 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, + render_worker: RenderWorkerHandle::spawn(), + render_state: RenderState::new(), + render_pending: false, // Initial geometry is already evaluated in AppState::new() + native_menu, + recent_files, + } + } + + /// Create a new NodeBox application instance (legacy constructor). + /// + /// This constructor creates a DesktopPort 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 DesktopPort internally for backwards compatibility. + /// Prefer using `new_with_port` for new code. pub fn new_with_file( cc: &eframe::CreationContext<'_>, initial_file: Option, @@ -59,21 +140,37 @@ impl NodeBoxApp { // Configure the global theme/style theme::configure_style(&cc.egui_ctx); + // Create a default DesktopPort for backwards compatibility + #[cfg(not(target_arch = "wasm32"))] + let port: Arc = Arc::new(nodebox_port::DesktopPort::new()); + #[cfg(target_arch = "wasm32")] + compile_error!("WASM builds must use new_with_port with a custom Port implementation"); + let mut state = AppState::new(); // Load recent files from disk let mut recent_files = RecentFiles::load(); - // Load the initial file if provided - if let Some(ref path) = initial_file { + // 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 { @@ -82,6 +179,8 @@ impl NodeBoxApp { let hash = Self::hash_library(&state.library); Self { + port, + project_context, state, address_bar: AddressBar::new(), viewer_pane: ViewerPane::new(), @@ -110,6 +209,8 @@ impl NodeBoxApp { let state = AppState::new(); let hash = Self::hash_library(&state.library); Self { + port: Arc::new(nodebox_port::DesktopPort::new()), + project_context: ProjectContext::new_unsaved(), state, address_bar: AddressBar::new(), viewer_pane: ViewerPane::new(), @@ -139,6 +240,8 @@ impl NodeBoxApp { state.geometry.clear(); let hash = Self::hash_library(&state.library); Self { + port: Arc::new(nodebox_port::DesktopPort::new()), + project_context: ProjectContext::new_unsaved(), state, address_bar: AddressBar::new(), viewer_pane: ViewerPane::new(), @@ -181,13 +284,38 @@ impl NodeBoxApp { &mut self.history } + /// Get a reference to the Port 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) { - self.state.evaluate(); + let (geometry, errors) = crate::eval::evaluate_network( + &self.state.library, + &self.port, + &self.project_context, + ); + if errors.is_empty() { + self.state.geometry = geometry; + 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. @@ -204,8 +332,8 @@ impl NodeBoxApp { self.previous_library_hash = current_hash; self.state.dirty = true; } - // Synchronously evaluate - self.state.evaluate(); + // Synchronously evaluate using the app's port + self.evaluate_for_testing(); } /// Compute a simple hash of the library for change detection. @@ -288,6 +416,8 @@ impl NodeBoxApp { id, self.state.library.clone(), cancel_token, + self.port.clone(), + self.project_context.clone(), ); self.render_pending = false; } @@ -562,7 +692,8 @@ impl eframe::App for NodeBoxApp { 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.parameters + .show(ui, &mut self.state, self.port.as_ref(), &self.project_context); }); // Bottom: Network pane (headers have their own borders) @@ -789,15 +920,23 @@ impl NodeBoxApp { } 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); - } else { - self.add_to_recent_files(path); + use nodebox_port::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), } } @@ -806,6 +945,11 @@ impl NodeBoxApp { 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()); } } @@ -839,50 +983,66 @@ impl NodeBoxApp { } 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); - } else { - self.add_to_recent_files(path); + use nodebox_port::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) { - 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); + use nodebox_port::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) { - 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); + use nodebox_port::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), } } } diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index f3f4c322..3483c6cb 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -1,10 +1,12 @@ //! 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::node::{Node, NodeLibrary, EvalError}; use nodebox_core::node::PortRange; use nodebox_core::Value; +use nodebox_port::{Port, ProjectContext}; use crate::render_worker::CancellationToken; /// Error information for a specific node. @@ -145,7 +147,13 @@ impl NodeOutput { /// 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. -pub fn evaluate_network(library: &NodeLibrary) -> (Vec, Vec) { +/// +/// 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, Vec) { let network = &library.root; // Find the rendered child @@ -161,7 +169,7 @@ pub fn evaluate_network(library: &NodeLibrary) -> (Vec, Vec) { let mut cache: HashMap = HashMap::new(); // Evaluate the rendered node (this will recursively evaluate dependencies) - let result = evaluate_node(network, &rendered_name, &mut cache); + let result = evaluate_node(network, &rendered_name, &mut cache, port, project_context); match result { Ok(output) => (output.to_paths(), Vec::new()), @@ -200,10 +208,14 @@ pub fn evaluate_network(library: &NodeLibrary) -> (Vec, Vec) { /// /// 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; @@ -236,6 +248,8 @@ pub fn evaluate_network_cancellable( &rendered_name, &mut eval_cache, cancel_token, + port, + project_context, ); // Convert cache back to NodeOutput format (preserve successful results) @@ -379,6 +393,8 @@ fn evaluate_node_cancellable( 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() { @@ -400,7 +416,7 @@ fn evaluate_node_cancellable( let mut inputs: HashMap = HashMap::new(); // For each input port, check if there are connections - for port in &node.inputs { + for node_port in &node.inputs { // Check cancellation during input collection if cancel_token.is_cancelled() { return Err(EvalError::Cancelled); @@ -409,12 +425,12 @@ fn evaluate_node_cancellable( // 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) + .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(port.name.clone(), value_to_output(&port.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( @@ -422,8 +438,10 @@ fn evaluate_node_cancellable( &connections[0].output_node, cache, cancel_token, + port, + project_context, )?; - inputs.insert(port.name.clone(), upstream_output); + inputs.insert(node_port.name.clone(), upstream_output); } else { // Multiple connections - collect all outputs as paths let mut all_paths: Vec = Vec::new(); @@ -433,10 +451,12 @@ fn evaluate_node_cancellable( &conn.output_node, cache, cancel_token, + port, + project_context, )?; all_paths.extend(upstream_output.to_paths()); } - inputs.insert(port.name.clone(), NodeOutput::Paths(all_paths)); + inputs.insert(node_port.name.clone(), NodeOutput::Paths(all_paths)); } } @@ -460,6 +480,8 @@ fn evaluate_node_cancellable( &conn.output_node, cache, cancel_token, + port, + project_context, )?; inputs.insert(conn.input_port.clone(), upstream_output); } else { @@ -471,6 +493,8 @@ fn evaluate_node_cancellable( &c.output_node, cache, cancel_token, + port, + project_context, )?; all_paths.extend(upstream_output.to_paths()); } @@ -485,9 +509,9 @@ fn evaluate_node_cancellable( let result = match iteration_count { None => { // Empty list input: still call execute_node to detect missing required inputs - execute_node(node, &inputs) + execute_node(node, &inputs, port, project_context) } - Some(1) => execute_node(node, &inputs), // Single iteration (optimization) + 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); @@ -498,7 +522,7 @@ fn evaluate_node_cancellable( } let iter_inputs = build_iteration_inputs(&inputs, node, i); - let result = execute_node(node, &iter_inputs)?; + let result = execute_node(node, &iter_inputs, port, project_context)?; results.push(result); } Ok(collect_results(results)) @@ -515,6 +539,8 @@ 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) { @@ -531,28 +557,28 @@ fn evaluate_node( let mut inputs: HashMap = HashMap::new(); // For each input port, check if there are connections - for port in &node.inputs { + 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 == port.name) + .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(port.name.clone(), value_to_output(&port.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)?; - inputs.insert(port.name.clone(), upstream_output); + 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)?; + let upstream_output = evaluate_node(network, &conn.output_node, cache, port, project_context)?; all_paths.extend(upstream_output.to_paths()); } - inputs.insert(port.name.clone(), NodeOutput::Paths(all_paths)); + inputs.insert(node_port.name.clone(), NodeOutput::Paths(all_paths)); } } @@ -567,13 +593,13 @@ fn evaluate_node( .collect(); if all_conns.len() == 1 { - let upstream_output = evaluate_node(network, &conn.output_node, cache)?; + 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)?; + 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)); @@ -588,15 +614,15 @@ fn evaluate_node( 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) + execute_node(node, &inputs, port, project_context) } - Some(1) => execute_node(node, &inputs), // Single iteration (optimization) + 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)?; + let result = execute_node(node, &iter_inputs, port, project_context)?; results.push(result); } Ok(collect_results(results)) @@ -719,7 +745,12 @@ fn require_paths(inputs: &HashMap, node_name: &str, port_nam } /// Execute a node and return its output. -fn execute_node(node: &Node, inputs: &HashMap) -> EvalResult { +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(), @@ -1098,7 +1129,22 @@ fn execute_node(node: &Node, inputs: &HashMap) -> EvalResult let centered = get_bool(inputs, "centered", true); let position = get_point(inputs, "position", Point::ZERO); - match nodebox_ops::import_svg(&file_path, centered, position) { + // 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 nodebox_ops::import_svg(&svg_content, centered, position) { Ok(geometry) => { if geometry.is_empty() { Ok(NodeOutput::None) @@ -1107,7 +1153,7 @@ fn execute_node(node: &Node, inputs: &HashMap) -> EvalResult } } Err(e) => { - log::warn!("SVG import error: {}", e); + log::warn!("SVG parse error: {}", e); Ok(NodeOutput::None) } } @@ -1131,7 +1177,13 @@ fn execute_node(node: &Node, inputs: &HashMap) -> EvalResult #[cfg(test)] mod tests { use super::*; - use nodebox_core::node::{Port, Connection, PortRange}; + use nodebox_core::node::{Port as NodePort, Connection, PortRange}; + use nodebox_port::{TestPort, ProjectContext}; + + /// Create a test port and project context for evaluation tests. + fn test_port_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPort::new()), ProjectContext::new_unsaved()) + } #[test] fn test_evaluate_simple_ellipse() { @@ -1140,13 +1192,14 @@ mod tests { .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_input(NodePort::point("position", Point::new(100.0, 100.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::float("height", 50.0)) ) .with_rendered_child("ellipse1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1161,22 +1214,23 @@ mod tests { .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_input(NodePort::point("position", Point::new(100.0, 100.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))) + .with_input(NodePort::color("stroke", Color::BLACK)) + .with_input(NodePort::float("strokeWidth", 2.0)) ) .with_connection(Connection::new("ellipse1", "colorize1", "shape")) .with_rendered_child("colorize1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); // Check that the colorize was applied @@ -1194,27 +1248,28 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::point("position", Point::new(100.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::float("height", 50.0)) ) .with_child( Node::new("merge1") .with_prototype("corevector.merge") - .with_input(Port::geometry("shapes")) + .with_input(NodePort::geometry("shapes")) ) .with_connection(Connection::new("ellipse1", "merge1", "shapes")) .with_connection(Connection::new("rect1", "merge1", "shapes")) .with_rendered_child("merge1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); // Merge collects all connected shapes assert_eq!(paths.len(), 2); } @@ -1226,13 +1281,14 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 80.0)) + .with_input(NodePort::float("height", 40.0)) ) .with_rendered_child("rect1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1247,13 +1303,14 @@ mod tests { .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_input(NodePort::point("point1", Point::new(0.0, 0.0))) + .with_input(NodePort::point("point2", Point::new(100.0, 50.0))) + .with_input(NodePort::int("points", 2)) ) .with_rendered_child("line1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1268,14 +1325,15 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("radius", 50.0)) + .with_input(NodePort::int("sides", 6)) + .with_input(NodePort::boolean("align", true)) ) .with_rendered_child("polygon1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _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) @@ -1291,14 +1349,15 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::int("points", 5)) + .with_input(NodePort::float("outer", 50.0)) + .with_input(NodePort::float("inner", 25.0)) ) .with_rendered_child("star1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); // Star with outer radius 50 should have bounds approximately 100x100 @@ -1313,16 +1372,17 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::float("height", 100.0)) + .with_input(NodePort::float("start_angle", 0.0)) + .with_input(NodePort::float("degrees", 180.0)) + .with_input(NodePort::string("type", "pie")) ) .with_rendered_child("arc1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); } @@ -1333,20 +1393,21 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::point("translate", Point::new(100.0, 50.0))) ) .with_connection(Connection::new("ellipse1", "translate1", "shape")) .with_rendered_child("translate1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1365,21 +1426,22 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::point("scale", Point::new(50.0, 200.0))) // 50% x, 200% y + .with_input(NodePort::point("origin", Point::ZERO)) ) .with_connection(Connection::new("ellipse1", "scale1", "shape")) .with_rendered_child("scale1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1395,24 +1457,25 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::int("copies", 3)) + .with_input(NodePort::string("order", "tsr")) + .with_input(NodePort::point("translate", Point::new(60.0, 0.0))) + .with_input(NodePort::float("rotate", 0.0)) + .with_input(NodePort::point("scale", Point::new(100.0, 100.0))) ) .with_connection(Connection::new("ellipse1", "copy1", "shape")) .with_rendered_child("copy1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); // Should have 3 copies assert_eq!(paths.len(), 3); } @@ -1420,7 +1483,8 @@ mod tests { #[test] fn test_evaluate_empty_network() { let library = NodeLibrary::new("test"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -1431,13 +1495,14 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::float("height", 50.0)) ); // No rendered_child set - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -1448,15 +1513,16 @@ mod tests { .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_input(NodePort::geometry("shape")) + .with_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))) + .with_input(NodePort::color("stroke", Color::BLACK)) + .with_input(NodePort::float("strokeWidth", 2.0)) ) .with_rendered_child("colorize1"); // Should handle missing input gracefully - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -1471,7 +1537,8 @@ mod tests { .with_rendered_child("unknown1"); // Should handle unknown node type gracefully - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -1482,20 +1549,21 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::int("points", 20)) ) .with_connection(Connection::new("ellipse1", "resample1", "shape")) .with_rendered_child("resample1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _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 @@ -1508,23 +1576,24 @@ mod tests { .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_input(NodePort::int("columns", 3)) + .with_input(NodePort::int("rows", 3)) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::float("height", 100.0)) + .with_input(NodePort::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_input(NodePort::geometry("points").with_port_range(PortRange::List)) + .with_input(NodePort::boolean("closed", false)) ) .with_connection(Connection::new("grid1", "connect1", "points")) .with_rendered_child("connect1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); } @@ -1541,13 +1610,14 @@ mod tests { .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_input(NodePort::point("position", Point::new(100.0, 50.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::float("height", 50.0)) ) .with_rendered_child("ellipse1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1567,13 +1637,14 @@ mod tests { .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_input(NodePort::point("position", Point::new(-50.0, 25.0))) + .with_input(NodePort::float("width", 80.0)) + .with_input(NodePort::float("height", 40.0)) ) .with_rendered_child("rect1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1591,14 +1662,15 @@ mod tests { .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_input(NodePort::point("position", Point::new(0.0, 0.0))) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::float("height", 100.0)) + .with_input(NodePort::point("roundness", Point::new(10.0, 10.0))) ) .with_rendered_child("rect1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _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 } @@ -1611,14 +1683,15 @@ mod tests { .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_input(NodePort::point("position", Point::new(200.0, -100.0))) + .with_input(NodePort::float("radius", 50.0)) + .with_input(NodePort::int("sides", 6)) + .with_input(NodePort::boolean("align", true)) ) .with_rendered_child("polygon1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1636,14 +1709,15 @@ mod tests { .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_input(NodePort::point("position", Point::new(75.0, 75.0))) + .with_input(NodePort::int("points", 5)) + .with_input(NodePort::float("outer", 50.0)) + .with_input(NodePort::float("inner", 25.0)) ) .with_rendered_child("star1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1662,16 +1736,17 @@ mod tests { .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_input(NodePort::point("position", Point::new(50.0, -50.0))) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::float("height", 100.0)) + .with_input(NodePort::float("start_angle", 0.0)) + .with_input(NodePort::float("degrees", 180.0)) + .with_input(NodePort::string("type", "pie")) ) .with_rendered_child("arc1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1689,24 +1764,25 @@ mod tests { .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_input(NodePort::point("position", Point::new(0.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::int("copies", 3)) + .with_input(NodePort::string("order", "tsr")) + .with_input(NodePort::point("translate", Point::new(60.0, 0.0))) + .with_input(NodePort::float("rotate", 0.0)) + .with_input(NodePort::point("scale", Point::new(100.0, 100.0))) ) .with_connection(Connection::new("ellipse1", "copy1", "shape")) .with_rendered_child("copy1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _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 @@ -1727,23 +1803,24 @@ mod tests { .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_input(NodePort::int("columns", 3)) + .with_input(NodePort::int("rows", 3)) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::float("height", 100.0)) + .with_input(NodePort::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_input(NodePort::geometry("points").with_port_range(PortRange::List)) + .with_input(NodePort::boolean("closed", false)) ) .with_connection(Connection::new("grid1", "connect1", "points")) .with_rendered_child("connect1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -1761,22 +1838,23 @@ mod tests { .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_input(NodePort::point("position", Point::new(0.0, 0.0))) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::string("scope", "points")) + .with_input(NodePort::point("offset", Point::new(10.0, 10.0))) + .with_input(NodePort::int("seed", 42)) ) .with_connection(Connection::new("ellipse1", "wiggle1", "shape")) .with_rendered_child("wiggle1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert!(!paths.is_empty(), "Wiggle should produce output"); } @@ -1789,23 +1867,24 @@ mod tests { .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_input(NodePort::point("position", Point::new(0.0, 0.0))) + .with_input(NodePort::float("width", 200.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::point("position", Point::new(100.0, 100.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::float("height", 50.0)) + .with_input(NodePort::boolean("keep_proportions", true)) ) .with_connection(Connection::new("ellipse1", "fit1", "shape")) .with_rendered_child("fit1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); // Verify fit produced output - the shape should be constrained to max 50x50 @@ -1857,38 +1936,39 @@ mod tests { .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_input(NodePort::point("position", Point::new(-100.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::point("position", Point::new(0.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::point("position", Point::new(100.0, 0.0))) + .with_input(NodePort::float("radius", 25.0)) + .with_input(NodePort::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_input(NodePort::geometry("list1").with_port_range(PortRange::List)) + .with_input(NodePort::geometry("list2").with_port_range(PortRange::List)) + .with_input(NodePort::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, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( paths.len(), @@ -1909,41 +1989,41 @@ mod tests { .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_input(NodePort::point("position", Point::new(-100.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::point("position", Point::new(0.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::point("position", Point::new(100.0, 0.0))) + .with_input(NodePort::float("radius", 25.0)) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::color("fill", Color::rgb(0.0, 0.0, 1.0))), ) .with_child( Node::new("combine1") @@ -1958,7 +2038,8 @@ mod tests { .with_connection(Connection::new("colorize3", "combine1", "list3")) .with_rendered_child("combine1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( paths.len(), @@ -1982,20 +2063,21 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))), ) .with_connection(Connection::new("rect1", "colorize1", "shape")) .with_rendered_child("colorize1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( paths.len(), @@ -2015,16 +2097,16 @@ mod tests { .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_input(NodePort::point("position", Point::new(-100.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::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_input(NodePort::point("position", Point::new(0.0, 0.0))) + .with_input(NodePort::float("width", 50.0)) + .with_input(NodePort::float("height", 50.0)), ) .with_child( Node::new("combine1") @@ -2035,7 +2117,8 @@ mod tests { .with_connection(Connection::new("ellipse1", "combine1", "list2")) .with_rendered_child("combine1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _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 @@ -2056,24 +2139,25 @@ mod tests { .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_input(NodePort::int("columns", 10)) + .with_input(NodePort::int("rows", 10)) + .with_input(NodePort::float("width", 300.0)) + .with_input(NodePort::float("height", 300.0)) + .with_input(NodePort::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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 20.0)) + .with_input(NodePort::float("height", 20.0)) + .with_input(NodePort::point("roundness", Point::ZERO)), ) .with_connection(Connection::new("grid1", "rect1", "position")) .with_rendered_child("rect1"); - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&library, &port, &ctx); // THE KEY ASSERTION: Must produce 100 rectangles, not 1! assert_eq!( @@ -2096,12 +2180,13 @@ mod tests { .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(NodePort::geometry("shape")) + .with_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))) ) .with_rendered_child("colorize1"); - let (paths, errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, 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()); @@ -2124,18 +2209,19 @@ mod tests { .with_child( Node::new("colorize1") .with_prototype("corevector.colorize") - .with_input(Port::geometry("shape")) + .with_input(NodePort::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_input(NodePort::geometry("shape")) + .with_input(NodePort::point("translate", Point::new(10.0, 10.0))) ) .with_connection(Connection::new("colorize1", "translate1", "shape")) .with_rendered_child("translate1"); - let (paths, errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, errors) = evaluate_network(&library, &port, &ctx); // Should have no output assert!(paths.is_empty(), "Should have no output when upstream has error"); @@ -2151,13 +2237,14 @@ mod tests { .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_input(NodePort::point("position", Point::ZERO)) + .with_input(NodePort::float("width", 100.0)) + .with_input(NodePort::float("height", 100.0)) ) .with_rendered_child("ellipse1"); - let (paths, errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, errors) = evaluate_network(&library, &port, &ctx); // Should have output assert!(!paths.is_empty(), "Should have output for valid network"); @@ -2174,11 +2261,12 @@ mod tests { .with_child( Node::new("my_colorize_node") .with_prototype("corevector.colorize") - .with_input(Port::geometry("shape")) + .with_input(NodePort::geometry("shape")) ) .with_rendered_child("my_colorize_node"); - let (_paths, errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (_paths, errors) = evaluate_network(&library, &port, &ctx); assert!(!errors.is_empty(), "Should have an error"); assert_eq!( @@ -2200,7 +2288,8 @@ mod tests { ) .with_rendered_child("ellipse1"); - let (paths, errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, 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"); diff --git a/crates/nodebox-gui/src/lib.rs b/crates/nodebox-gui/src/lib.rs index 5ea816be..66d22e0b 100644 --- a/crates/nodebox-gui/src/lib.rs +++ b/crates/nodebox-gui/src/lib.rs @@ -69,23 +69,25 @@ 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 DesktopPort 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 port for file operations + let port: Arc = Arc::new(nodebox_port::DesktopPort::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 { @@ -100,6 +102,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/panels.rs b/crates/nodebox-gui/src/panels.rs index ecb646fb..7212cecd 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -3,6 +3,7 @@ use eframe::egui::{self, Sense, TextStyle}; use nodebox_core::node::{PortType, Widget}; use nodebox_core::Value; +use nodebox_port::{FileFilter, Port, PortError, ProjectContext}; use crate::components; use crate::state::AppState; use crate::theme; @@ -32,7 +33,13 @@ impl ParameterPanel { } /// Show the parameter panel. - pub fn show(&mut self, ui: &mut egui::Ui, state: &mut AppState) { + pub fn show( + &mut self, + ui: &mut egui::Ui, + state: &mut AppState, + port: &dyn Port, + project_context: &ProjectContext, + ) { // Apply minimal styling for the panel ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 2.0); @@ -93,9 +100,16 @@ impl ParameterPanel { 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); + 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, + port, + project_context, + ); } }); } else { @@ -114,6 +128,8 @@ impl ParameterPanel { port: &mut nodebox_core::node::Port, is_connected: bool, node_name: &str, + io_port: &dyn Port, + project_context: &ProjectContext, ) { ui.horizontal(|ui| { ui.set_height(theme::PARAMETER_ROW_HEIGHT); @@ -151,13 +167,20 @@ impl ParameterPanel { 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); + self.show_port_editor(ui, port, node_name, io_port, project_context); } }); } /// 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) { + fn show_port_editor( + &mut self, + ui: &mut egui::Ui, + port: &mut nodebox_core::node::Port, + node_name: &str, + io_port: &dyn Port, + project_context: &ProjectContext, + ) { let port_key = (node_name.to_string(), port.name.clone()); // Check if we're editing this port @@ -356,13 +379,37 @@ impl ParameterPanel { } if button_response.clicked() { - // Open file dialog - if let Some(picked_path) = rfd::FileDialog::new() - .add_filter("SVG files", &["svg"]) - .add_filter("All files", &["*"]) - .pick_file() - { - *path = picked_path.to_string_lossy().to_string(); + // Check if project is saved first + if !project_context.is_saved() { + // Show message to save project first + let _ = io_port.show_message_dialog( + "Save Project First", + "Please save your project before importing files.", + &["OK"], + ); + } else { + // Use sandboxed file dialog through Port + match io_port.show_open_file_dialog( + project_context, + &[FileFilter::svg()], + ) { + Ok(Some(relative_path)) => { + // Store the relative path + *path = relative_path.to_string(); + } + Ok(None) => {} // User cancelled + Err(PortError::SandboxViolation) => { + // File is outside project directory + 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); + } + } } } } diff --git a/crates/nodebox-gui/src/render_worker.rs b/crates/nodebox-gui/src/render_worker.rs index 5cb473a3..83081916 100644 --- a/crates/nodebox-gui/src/render_worker.rs +++ b/crates/nodebox-gui/src/render_worker.rs @@ -7,6 +7,7 @@ use std::thread; use std::time::Instant; use nodebox_core::geometry::Path as GeoPath; use nodebox_core::node::NodeLibrary; +use nodebox_port::{Port, ProjectContext}; use crate::eval::{NodeError, NodeOutput}; /// Token for cooperative cancellation of render operations. @@ -56,6 +57,8 @@ pub enum RenderRequest { id: RenderRequestId, library: NodeLibrary, cancel_token: CancellationToken, + port: Arc, + project_context: ProjectContext, }, /// Shut down the worker thread. Shutdown, @@ -177,12 +180,16 @@ impl RenderWorkerHandle { id: RenderRequestId, library: NodeLibrary, 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, }); } } @@ -222,10 +229,10 @@ fn render_worker_loop( loop { match request_rx.recv() { - Ok(RenderRequest::Evaluate { id, library, cancel_token }) => { + 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) = - drain_to_latest(id, library, cancel_token, &request_rx); + 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. @@ -236,6 +243,8 @@ fn render_worker_loop( &final_library, &final_token, &mut node_cache, + &final_port, + &final_project_context, ); match result { @@ -263,21 +272,27 @@ fn drain_to_latest( mut id: RenderRequestId, mut library: NodeLibrary, mut cancel_token: CancellationToken, + mut port: Arc, + mut project_context: ProjectContext, rx: &mpsc::Receiver, -) -> (RenderRequestId, NodeLibrary, CancellationToken) { +) -> (RenderRequestId, NodeLibrary, 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) + (id, library, cancel_token, port, project_context) } diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index 29f57a60..4f340550 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; 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 { @@ -41,43 +40,21 @@ impl Default for AppState { 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 { - // Create a demo node library let library = Self::create_demo_library(); - // Evaluate the network to get the initial geometry - let (geometry, errors) = eval::evaluate_network(&library); - let node_errors: HashMap = errors - .into_iter() - .map(|e| (e.node_name, e.message)) - .collect(); - Self { current_file: None, dirty: false, show_about: false, - geometry, + geometry: Vec::new(), // Render worker will populate selected_node: None, background_color: Color::WHITE, library, - node_errors, - } - } - - /// Re-evaluate the network and update the geometry. - #[allow(dead_code)] - pub fn evaluate(&mut self) { - let (geometry, errors) = eval::evaluate_network(&self.library); - if errors.is_empty() { - // Success: update geometry and clear errors - self.geometry = geometry; - self.node_errors.clear(); - } else { - // Errors: keep last geometry, populate errors - self.node_errors = errors - .into_iter() - .map(|e| (e.node_name, e.message)) - .collect(); + node_errors: HashMap::new(), } } @@ -112,6 +89,9 @@ impl AppState { } /// 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 let mut library = nodebox_ndbx::parse_file(path).map_err(|e| e.to_string())?; @@ -124,19 +104,8 @@ impl AppState { self.current_file = Some(path.to_path_buf()); self.dirty = false; self.selected_node = None; - - // Evaluate the network - let (geometry, errors) = eval::evaluate_network(&self.library); - if errors.is_empty() { - self.geometry = geometry; - self.node_errors.clear(); - } else { - // Keep previous geometry on error, update error state - self.node_errors = errors - .into_iter() - .map(|e| (e.node_name, e.message)) - .collect(); - } + self.geometry.clear(); // Render worker will populate + self.node_errors.clear(); Ok(()) } diff --git a/crates/nodebox-gui/tests/cancellation_tests.rs b/crates/nodebox-gui/tests/cancellation_tests.rs index fc7981af..7ed68e9e 100644 --- a/crates/nodebox-gui/tests/cancellation_tests.rs +++ b/crates/nodebox-gui/tests/cancellation_tests.rs @@ -1,12 +1,19 @@ //! 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_gui::eval::{EvalOutcome, NodeOutput, evaluate_network_cancellable}; use nodebox_gui::render_worker::CancellationToken; +use nodebox_port::{Port as PortTrait, ProjectContext, TestPort}; + +/// Create a test port and project context for evaluation tests. +fn test_port_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPort::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 { @@ -64,7 +71,8 @@ fn test_evaluation_completes_without_cancellation() { let token = CancellationToken::new(); let mut cache: HashMap = HashMap::new(); - let outcome = evaluate_network_cancellable(&library, &token, &mut cache); + let (port, ctx) = test_port_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); match outcome { EvalOutcome::Completed { geometry, errors } => { @@ -86,7 +94,8 @@ fn test_evaluation_cancelled_immediately() { // Cancel immediately token.cancel(); - let outcome = evaluate_network_cancellable(&library, &token, &mut cache); + let (port, ctx) = test_port_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); match outcome { EvalOutcome::Completed { .. } => { @@ -111,7 +120,8 @@ fn test_cache_preserved_after_cancellation() { token_clone.cancel(); }); - let outcome = evaluate_network_cancellable(&library, &token, &mut cache); + let (port, ctx) = test_port_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 @@ -133,7 +143,8 @@ fn test_cache_reused_after_cancellation() { // First render - complete fully let token1 = CancellationToken::new(); - let outcome1 = evaluate_network_cancellable(&library, &token1, &mut cache); + let (port, ctx) = test_port_and_context(); + let outcome1 = evaluate_network_cancellable(&library, &token1, &mut cache, &port, &ctx); match outcome1 { EvalOutcome::Completed { geometry, errors } => { @@ -149,7 +160,7 @@ fn test_cache_reused_after_cancellation() { // Second render - should reuse cache let token2 = CancellationToken::new(); - let outcome2 = evaluate_network_cancellable(&library, &token2, &mut cache); + let outcome2 = evaluate_network_cancellable(&library, &token2, &mut cache, &port, &ctx); match outcome2 { EvalOutcome::Completed { geometry, errors } => { @@ -174,7 +185,9 @@ fn test_cancellation_response_time() { let library_clone = library.clone(); let handle = thread::spawn(move || { let mut thread_cache: HashMap = HashMap::new(); - evaluate_network_cancellable(&library_clone, &token_for_thread, &mut thread_cache) + let port: Arc = Arc::new(TestPort::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 @@ -207,7 +220,8 @@ fn test_multiple_rapid_cancellations() { token.cancel(); } - let outcome = evaluate_network_cancellable(&library, &token, &mut cache); + let (port, ctx) = test_port_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); // Should not panic or hang regardless of timing match outcome { @@ -226,7 +240,8 @@ fn test_empty_network_not_affected_by_cancellation() { token.cancel(); // Pre-cancel - let outcome = evaluate_network_cancellable(&library, &token, &mut cache); + let (port, ctx) = test_port_and_context(); + let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); // Empty network should complete (nothing to cancel) match outcome { diff --git a/crates/nodebox-gui/tests/file_tests.rs b/crates/nodebox-gui/tests/file_tests.rs index 53fbc8e8..37cb741a 100644 --- a/crates/nodebox-gui/tests/file_tests.rs +++ b/crates/nodebox-gui/tests/file_tests.rs @@ -1,9 +1,16 @@ //! Tests for loading and evaluating .ndbx files from the examples directory. use std::path::PathBuf; +use std::sync::Arc; use nodebox_gui::eval::evaluate_network; use nodebox_gui::{populate_default_ports, AppState}; +use nodebox_port::{Port, ProjectContext, TestPort}; + +/// Create a test port and project context for evaluation tests. +fn test_port_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPort::new()), ProjectContext::new_unsaved()) +} /// Get the path to the examples directory. fn examples_dir() -> PathBuf { @@ -117,17 +124,20 @@ fn test_evaluate_primitives() { test_library.root = library.root.clone(); test_library.root.rendered_child = Some("rect1".to_string()); - let (paths, _errors) = evaluate_network(&test_library); + let (port, ctx) = test_port_and_context(); + let (paths, _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 (paths, _errors) = evaluate_network(&test_library); + let (port, ctx) = test_port_and_context(); + let (paths, _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 (paths, _errors) = evaluate_network(&test_library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&test_library, &port, &ctx); assert_eq!(paths.len(), 1, "polygon1 should produce one path"); } @@ -137,7 +147,8 @@ fn test_evaluate_primitives_full() { // The rendered child is "combine1" which uses list.combine // Now that list.combine is implemented, we can evaluate the full network - let (paths, _errors) = evaluate_network(&library); + let (port, ctx) = test_port_and_context(); + let (paths, _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"); @@ -157,7 +168,8 @@ fn test_evaluate_colorized_primitives() { // Test colorized rect (colorize1 <- rect1) test_library.root.rendered_child = Some("colorize1".to_string()); - let (paths, _errors) = evaluate_network(&test_library); + let (port, ctx) = test_port_and_context(); + let (paths, _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"); @@ -179,7 +191,8 @@ fn test_evaluate_copy() { test_library.root = library.root.clone(); test_library.root.rendered_child = Some(copy.name.clone()); - let (paths, _errors) = evaluate_network(&test_library); + let (port, ctx) = test_port_and_context(); + let (paths, _errors) = evaluate_network(&test_library, &port, &ctx); // Copy should produce multiple paths assert!( !paths.is_empty(), @@ -246,8 +259,11 @@ fn test_app_state_load_file() { 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"); + // Geometry is now evaluated by render worker, not load_file. + // Verify we can evaluate the loaded library (should have 3 shapes). + let (port, ctx) = test_port_and_context(); + let (geometry, _errors) = evaluate_network(&state.library, &port, &ctx); + assert_eq!(geometry.len(), 3, "Should have 3 rendered shapes"); } #[test] @@ -278,21 +294,22 @@ fn test_primitives_shapes_at_different_positions() { 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, _errors) = evaluate_network(&test_library); + let (port, ctx) = test_port_and_context(); + let (rect_paths, _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, _errors) = evaluate_network(&test_library); + let (ellipse_paths, _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, _errors) = evaluate_network(&test_library); + let (polygon_paths, _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; diff --git a/crates/nodebox-ops/src/svg.rs b/crates/nodebox-ops/src/svg.rs index dbd67c08..0644c041 100644 --- a/crates/nodebox-ops/src/svg.rs +++ b/crates/nodebox-ops/src/svg.rs @@ -1,15 +1,21 @@ //! SVG import functionality. //! -//! This module provides functions to import SVG files and convert them to NodeBox geometry. +//! 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 nodebox_core::geometry::{Color, Contour, Geometry, Path, Point, Transform}; -use std::path::Path as StdPath; use usvg::{tiny_skia_path::PathSegment, Tree}; -/// Import an SVG file and convert it to NodeBox Geometry. +/// 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 -/// * `file_path` - Path to the SVG file +/// * `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 /// @@ -23,28 +29,19 @@ use usvg::{tiny_skia_path::PathSegment, Tree}; /// use nodebox_core::geometry::Point; /// use nodebox_ops::import_svg; /// -/// let geometry = import_svg("shape.svg", true, Point::ZERO)?; +/// let svg_content = r#""#; +/// let geometry = import_svg(svg_content, true, Point::ZERO)?; /// ``` -pub fn import_svg(file_path: &str, centered: bool, position: Point) -> Result { - // Empty path returns empty geometry - if file_path.is_empty() { +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()); } - // Check if file exists - let path = StdPath::new(file_path); - if !path.exists() { - return Err(format!("SVG file not found: {}", file_path)); - } - - // Read and parse the SVG file - let svg_data = - std::fs::read(file_path).map_err(|e| format!("Failed to read SVG file: {}", e))?; - // Parse SVG using usvg with default options let options = usvg::Options::default(); - let tree = - Tree::from_data(&svg_data, &options).map_err(|e| format!("Failed to parse SVG: {}", e))?; + 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); @@ -238,17 +235,17 @@ mod tests { use super::*; #[test] - fn test_import_empty_path() { + 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_missing_file() { - let result = import_svg("/nonexistent/path/to/file.svg", false, Point::ZERO); + 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("not found")); + assert!(result.unwrap_err().contains("Failed to parse SVG")); } #[test] @@ -289,22 +286,14 @@ mod tests { } #[test] - fn test_import_svg_from_data() { - // Test importing from an in-memory SVG - use std::io::Write; - use tempfile::NamedTempFile; - + fn test_import_svg_from_content() { let svg_content = r##" "##; - // Write to a temp file - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(svg_content.as_bytes()).unwrap(); - - let result = import_svg(temp_file.path().to_str().unwrap(), false, Point::ZERO); + let result = import_svg(svg_content, false, Point::ZERO); assert!(result.is_ok(), "Failed to import SVG: {:?}", result); let geometry = result.unwrap(); @@ -320,24 +309,17 @@ mod tests { #[test] fn test_import_svg_centered() { - use std::io::Write; - use tempfile::NamedTempFile; - let svg_content = r##" "##; - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(svg_content.as_bytes()).unwrap(); - let path = temp_file.path().to_str().unwrap(); - // Import without centering - let not_centered = import_svg(path, false, Point::ZERO).unwrap(); + 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(path, true, Point::ZERO).unwrap(); + let centered = import_svg(svg_content, true, Point::ZERO).unwrap(); let bounds_centered = centered.bounds().unwrap(); // Centered version should have center at (0, 0) @@ -364,19 +346,13 @@ mod tests { #[test] fn test_import_svg_with_position() { - use std::io::Write; - use tempfile::NamedTempFile; - let svg_content = r##" "##; - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(svg_content.as_bytes()).unwrap(); - let offset = Point::new(200.0, 300.0); - let result = import_svg(temp_file.path().to_str().unwrap(), true, offset).unwrap(); + let result = import_svg(svg_content, true, offset).unwrap(); let bounds = result.bounds().unwrap(); // Center should be at the offset position @@ -395,18 +371,12 @@ mod tests { #[test] fn test_import_svg_colors() { - use std::io::Write; - use tempfile::NamedTempFile; - let svg_content = r##" "##; - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(svg_content.as_bytes()).unwrap(); - - let result = import_svg(temp_file.path().to_str().unwrap(), false, Point::ZERO).unwrap(); + let result = import_svg(svg_content, false, Point::ZERO).unwrap(); assert!(!result.is_empty()); let path = &result.paths[0]; diff --git a/crates/nodebox-port/Cargo.toml b/crates/nodebox-port/Cargo.toml new file mode 100644 index 00000000..26c5dfca --- /dev/null +++ b/crates/nodebox-port/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "nodebox-port" +description = "Platform abstraction layer for NodeBox" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[lib] +name = "nodebox_port" +path = "src/lib.rs" + +[dependencies] +# Error handling +thiserror.workspace = true + +# Logging +log = "0.4" + +# File dialogs (desktop only) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = "0.15" + +# Clipboard (desktop only) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.arboard] +version = "3" + +# HTTP client (desktop only, blocking) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.ureq] +version = "2" + +# Platform directories (desktop only) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.directories] +version = "5" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/nodebox-port/src/desktop.rs b/crates/nodebox-port/src/desktop.rs new file mode 100644 index 00000000..939086b3 --- /dev/null +++ b/crates/nodebox-port/src/desktop.rs @@ -0,0 +1,851 @@ +//! Desktop (macOS, Windows, Linux) implementation of the Port trait. + +use crate::{ + DirectoryEntry, FileFilter, LogLevel, PlatformInfo, Port, PortError, ProjectContext, + RelativePath, +}; +use std::path::{Path, PathBuf}; + +/// Desktop implementation of the Port trait. +/// +/// Uses native filesystem, rfd for dialogs, arboard for clipboard, and ureq for HTTP. +#[derive(Debug, Default)] +pub struct DesktopPort; + +impl DesktopPort { + /// Create a new DesktopPort 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(PortError::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(PortError::Unsupported)?; + + // Canonicalize both paths to resolve symlinks and normalize + let canonical_root = root.canonicalize().map_err(|e| { + PortError::IoError(format!("Failed to canonicalize project root: {}", e)) + })?; + let canonical_path = path.canonicalize().map_err(|e| { + PortError::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(|_| PortError::SandboxViolation)?; + RelativePath::new(relative) + } else { + Err(PortError::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(PortError::Unsupported)?; + + // Canonicalize the project root + let canonical_root = root.canonicalize().map_err(|e| { + PortError::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| { + PortError::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| { + PortError::IoError(format!( + "Failed to canonicalize ancestor: {}", + e + )) + })?; + } + if let Some(p) = ancestor.parent() { + ancestor = p.to_path_buf(); + } else { + return Err(PortError::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(|_| PortError::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(PortError::SandboxViolation) + } + } else { + Err(PortError::SandboxViolation) + } + } else { + // Path has no parent - just a filename, which is fine + RelativePath::new(path) + } + } +} + +impl Port for DesktopPort { + fn platform_info(&self) -> PlatformInfo { + PlatformInfo::current() + } + + fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PortError> { + let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + let full_path = root.join(path.as_path()); + std::fs::read(&full_path).map_err(PortError::from) + } + + fn write_file( + &self, + ctx: &ProjectContext, + path: &RelativePath, + data: &[u8], + ) -> Result<(), PortError> { + let root = ctx.root.as_ref().ok_or(PortError::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(PortError::from) + } + + fn list_directory( + &self, + ctx: &ProjectContext, + path: &RelativePath, + ) -> Result, PortError> { + let root = ctx.root.as_ref().ok_or(PortError::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(PortError::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(|_| PortError::IoError("Invalid UTF-8".to_string())) + } + + fn read_binary_file(&self, ctx: &ProjectContext, path: &str) -> Result, PortError> { + let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + let relative = RelativePath::new(path)?; + let full_path = root.join(relative.as_path()); + std::fs::read(&full_path).map_err(PortError::from) + } + + fn load_app_resource(&self, name: &str) -> Result, PortError> { + // 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(PortError::from); + } + } + + Err(PortError::NotFound) + } + + fn read_project(&self, ctx: &ProjectContext) -> Result, PortError> { + let path = ctx.project_path().ok_or(PortError::Unsupported)?; + std::fs::read(&path).map_err(PortError::from) + } + + fn write_project(&self, ctx: &ProjectContext, data: &[u8]) -> Result<(), PortError> { + let path = ctx.project_path().ok_or(PortError::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(PortError::from) + } + + fn load_library(&self, name: &str) -> Result, PortError> { + let library_dir = self.library_dir()?; + let library_path = library_dir.join(format!("{}.ndbx", name)); + + if !library_path.exists() { + return Err(PortError::LibraryNotFound(name.to_string())); + } + + std::fs::read(&library_path).map_err(PortError::from) + } + + fn http_get(&self, url: &str) -> Result, PortError> { + let response = ureq::get(url) + .call() + .map_err(|e| PortError::NetworkError(e.to_string()))?; + + let mut bytes = Vec::new(); + response + .into_reader() + .read_to_end(&mut bytes) + .map_err(|e| PortError::NetworkError(e.to_string()))?; + + Ok(bytes) + } + + fn show_open_project_dialog( + &self, + filters: &[FileFilter], + ) -> Result, PortError> { + 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, PortError> { + 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, PortError> { + let root = ctx.root.as_ref().ok_or(PortError::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, PortError> { + let root = ctx.root.as_ref().ok_or(PortError::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, PortError> { + let root = ctx.root.as_ref().ok_or(PortError::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, PortError> { + // 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, PortError> { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| PortError::Other(e.to_string()))?; + + match clipboard.get_text() { + Ok(text) => Ok(Some(text)), + Err(arboard::Error::ContentNotAvailable) => Ok(None), + Err(e) => Err(PortError::Other(e.to_string())), + } + } + + fn clipboard_write_text(&self, text: &str) -> Result<(), PortError> { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| PortError::Other(e.to_string()))?; + + clipboard + .set_text(text) + .map_err(|e| PortError::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(PortError::Other( + "Could not determine config directory".to_string(), + )) + } + } +} + +#[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 = DesktopPort::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 = DesktopPort::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 = DesktopPort::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 = DesktopPort::new(); + + let path = RelativePath::new("nonexistent.txt").unwrap(); + let result = port.read_file(&ctx, &path); + + assert!(matches!(result, Err(PortError::NotFound))); + } + + #[test] + fn test_list_directory() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPort::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 = DesktopPort::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 = DesktopPort::new(); + let result = port.load_library("nonexistent_library_xyz"); + + assert!(matches!(result, Err(PortError::LibraryNotFound(_)))); + } + + #[test] + fn test_get_config_dir() { + let port = DesktopPort::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 = DesktopPort::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 = DesktopPort::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 = DesktopPort::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 = DesktopPort::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 = DesktopPort::validate_within_project(&ctx, &outside_file); + assert!(matches!(result, Err(PortError::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 = DesktopPort::validate_within_project(&ctx, &parent_file); + assert!(matches!(result, Err(PortError::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 = DesktopPort::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 = DesktopPort::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 = DesktopPort::validate_save_path_within_project(&ctx, &outside_path); + assert!(matches!(result, Err(PortError::SandboxViolation))); + } + + #[test] + fn test_read_text_file() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPort::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 = DesktopPort::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 = DesktopPort::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(PortError::IoError(_)))); + } + + #[test] + fn test_read_text_file_rejects_sandbox_violation() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPort::new(); + + // Try to read with ".." in path + let result = port.read_text_file(&ctx, "../escape.txt"); + assert!(matches!(result, Err(PortError::SandboxViolation))); + + // Try with absolute path + let result = port.read_text_file(&ctx, "/etc/passwd"); + assert!(matches!(result, Err(PortError::SandboxViolation))); + } + + #[test] + fn test_read_text_file_unsaved_project() { + let port = DesktopPort::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(PortError::Unsupported))); + } + + #[test] + fn test_read_binary_file() { + let (_temp_dir, ctx) = create_test_context(); + let port = DesktopPort::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 = DesktopPort::new(); + + // Try to read with ".." in path + let result = port.read_binary_file(&ctx, "../escape.bin"); + assert!(matches!(result, Err(PortError::SandboxViolation))); + } + + #[test] + fn test_load_app_resource_not_found() { + let port = DesktopPort::new(); + + // Resources that don't exist should return NotFound + let result = port.load_app_resource("nonexistent/icon.png"); + assert!(matches!(result, Err(PortError::NotFound))); + } +} diff --git a/crates/nodebox-port/src/lib.rs b/crates/nodebox-port/src/lib.rs new file mode 100644 index 00000000..d47d6152 --- /dev/null +++ b/crates/nodebox-port/src/lib.rs @@ -0,0 +1,1096 @@ +//! Platform abstraction layer for NodeBox. +//! +//! The Port 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 `Port` trait; +//! unsupported operations return `Err(PortError::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 +//! +//! # Example +//! +//! ```no_run +//! use nodebox_port::{Port, ProjectContext, RelativePath, DesktopPort}; +//! use std::path::PathBuf; +//! +//! let port = DesktopPort::new(); +//! let ctx = ProjectContext::new("/path/to/project", "project.ndbx"); +//! +//! // Read a file relative to project root using the convenience method +//! let svg_content = port.read_text_file(&ctx, "assets/logo.svg"); +//! +//! // Or use the lower-level API with RelativePath +//! let path = RelativePath::new("assets/image.png").unwrap(); +//! let data = port.read_file(&ctx, &path); +//! ``` + +#[cfg(not(target_arch = "wasm32"))] +mod desktop; + +#[cfg(not(target_arch = "wasm32"))] +pub use desktop::DesktopPort; + +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, +} + +impl ProjectContext { + /// Create context for a new unsaved project. + pub fn new_unsaved() -> Self { + Self { + root: None, + project_file: None, + } + } + + /// 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()), + } + } + + /// 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 `PortError::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(PortError::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(PortError::SandboxViolation); + } + } + } + + // Check for ".." components that could escape the sandbox + for component in path.components() { + if let std::path::Component::ParentDir = component { + return Err(PortError::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 `PortError::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()]) + } +} + +/// 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 PortError { + /// 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 PortError { + fn from(err: std::io::Error) -> Self { + match err.kind() { + std::io::ErrorKind::NotFound => PortError::NotFound, + std::io::ErrorKind::PermissionDenied => PortError::PermissionDenied, + _ => PortError::IoError(err.to_string()), + } + } +} + +/// The main Port trait for platform abstraction. +/// +/// Implementations of this trait provide platform-specific behavior for +/// file I/O, dialogs, clipboard, network, and other operations. +pub trait Port: Send + Sync { + // === Platform Info === + + /// Get information about the current platform. + fn platform_info(&self) -> PlatformInfo; + + // === File Operations === + + /// Read a file from the project directory. + /// + /// # Arguments + /// + /// * `ctx` - Project context containing the root directory + /// * `path` - Path relative to project root + /// + /// # Errors + /// + /// * `PortError::NotFound` - File does not exist + /// * `PortError::PermissionDenied` - No permission to read file + /// * `PortError::IoError` - Other I/O error + fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PortError>; + + /// Write a file to the project directory. + /// + /// Creates parent directories if they don't exist. + /// + /// # Arguments + /// + /// * `ctx` - Project context containing the root directory + /// * `path` - Path relative to project root + /// * `data` - Data to write + /// + /// # Errors + /// + /// * `PortError::PermissionDenied` - No permission to write file + /// * `PortError::IoError` - Other I/O error + fn write_file( + &self, + ctx: &ProjectContext, + path: &RelativePath, + data: &[u8], + ) -> Result<(), PortError>; + + /// List contents of a directory within the project. + /// + /// # Arguments + /// + /// * `ctx` - Project context containing the root directory + /// * `path` - Path relative to project root + /// + /// # Errors + /// + /// * `PortError::NotFound` - Directory does not exist + /// * `PortError::PermissionDenied` - No permission to read directory + /// * `PortError::IoError` - Other I/O error + fn list_directory( + &self, + ctx: &ProjectContext, + path: &RelativePath, + ) -> Result, PortError>; + + // === Convenience File Operations === + + /// Read a text file (UTF-8) from the project directory. + /// + /// This is a convenience method that validates the path is relative and within + /// the project sandbox, then reads the file as UTF-8 text. + /// + /// # Arguments + /// + /// * `ctx` - Project context containing the root directory + /// * `path` - Path string relative to project root + /// + /// # Errors + /// + /// * `PortError::Unsupported` - Project has no root directory (unsaved project) + /// * `PortError::SandboxViolation` - Path contains "..", is absolute, etc. + /// * `PortError::NotFound` - File does not exist + /// * `PortError::IoError` - Other I/O error or invalid UTF-8 + fn read_text_file(&self, ctx: &ProjectContext, path: &str) -> Result; + + /// Read a binary file from the project directory. + /// + /// This is a convenience method that validates the path is relative and within + /// the project sandbox, then reads the file as binary data. + /// + /// # Arguments + /// + /// * `ctx` - Project context containing the root directory + /// * `path` - Path string relative to project root + /// + /// # Errors + /// + /// * `PortError::Unsupported` - Project has no root directory (unsaved project) + /// * `PortError::SandboxViolation` - Path contains "..", is absolute, etc. + /// * `PortError::NotFound` - File does not exist + /// * `PortError::IoError` - Other I/O error + fn read_binary_file(&self, ctx: &ProjectContext, path: &str) -> Result, PortError>; + + /// Load an application resource (icons, fonts, etc.) + /// + /// These are bundled with the application, not project-specific. + /// Resources are located relative to the application executable or bundle. + /// + /// # Arguments + /// + /// * `name` - Resource path relative to resources directory (e.g., "icons/add.png") + /// + /// # Errors + /// + /// * `PortError::NotFound` - Resource does not exist + /// * `PortError::IoError` - Other I/O error + fn load_app_resource(&self, name: &str) -> Result, PortError>; + + // === Project File (special handling) === + + /// Read the project file. + /// + /// # Arguments + /// + /// * `ctx` - Project context containing the root directory and project file name + /// + /// # Errors + /// + /// * `PortError::NotFound` - Project file does not exist + /// * `PortError::IoError` - Other I/O error + fn read_project(&self, ctx: &ProjectContext) -> Result, PortError>; + + /// Write the project file. + /// + /// # Arguments + /// + /// * `ctx` - Project context containing the root directory and project file name + /// * `data` - Project data to write + /// + /// # Errors + /// + /// * `PortError::PermissionDenied` - No permission to write file + /// * `PortError::IoError` - Other I/O error + fn write_project(&self, ctx: &ProjectContext, data: &[u8]) -> Result<(), PortError>; + + // === Library Access === + + /// Load a library by name. + /// + /// Libraries are located in platform-specific directories: + /// - macOS: `~/Library/Application Support/net.nodebox.NodeBox/libraries/` + /// - Windows: `%APPDATA%\NodeBox\libraries\` + /// - Linux: `~/.local/share/nodebox/libraries/` + /// + /// # Arguments + /// + /// * `name` - Name of the library (without extension) + /// + /// # Errors + /// + /// * `PortError::LibraryNotFound` - Library does not exist + /// * `PortError::IoError` - Other I/O error + fn load_library(&self, name: &str) -> Result, PortError>; + + // === Network === + + /// Perform an HTTP GET request. + /// + /// # Arguments + /// + /// * `url` - URL to fetch + /// + /// # Errors + /// + /// * `PortError::NetworkError` - Network request failed + /// * `PortError::Unsupported` - Network not available on this platform + fn http_get(&self, url: &str) -> Result, PortError>; + + // === Dialogs (Project-level, return absolute paths) === + + /// Show dialog to open a project file (no sandbox restriction). + /// + /// This is for opening .ndbx project files and returns an absolute path. + /// Use this when the user wants to open an existing project. + /// + /// # Arguments + /// + /// * `filters` - File type filters to show + /// + /// # Returns + /// + /// * `Ok(Some(path))` - User selected a file + /// * `Ok(None)` - User cancelled + /// * `Err(PortError::Unsupported)` - Dialogs not available + fn show_open_project_dialog( + &self, + filters: &[FileFilter], + ) -> Result, PortError>; + + /// Show dialog to choose where to save a new project. + /// + /// This is for "Save As" operations and returns an absolute path. + /// Use this to establish the project location for new/unsaved projects. + /// + /// # Arguments + /// + /// * `filters` - File type filters to show + /// * `default_name` - Optional default filename + /// + /// # Returns + /// + /// * `Ok(Some(path))` - User selected a save location + /// * `Ok(None)` - User cancelled + /// * `Err(PortError::Unsupported)` - Dialogs not available + fn show_save_project_dialog( + &self, + filters: &[FileFilter], + default_name: Option<&str>, + ) -> Result, PortError>; + + // === Dialogs (Asset-level, sandboxed to project) === + + /// Show "Open File" dialog for importing assets. + /// + /// The selected file must be within the project directory. If the user + /// selects a file outside the project, returns `Err(PortError::SandboxViolation)`. + /// + /// # Arguments + /// + /// * `ctx` - Project context (required for sandbox validation) + /// * `filters` - File type filters to show + /// + /// # Returns + /// + /// * `Ok(Some(path))` - User selected a valid file within project + /// * `Ok(None)` - User cancelled + /// * `Err(PortError::SandboxViolation)` - Selected file is outside project directory + /// * `Err(PortError::Unsupported)` - Dialogs not available + fn show_open_file_dialog( + &self, + ctx: &ProjectContext, + filters: &[FileFilter], + ) -> Result, PortError>; + + /// Show "Save File" dialog for exporting assets. + /// + /// The selected location must be within the project directory. If the user + /// selects a location outside the project, returns `Err(PortError::SandboxViolation)`. + /// + /// # Arguments + /// + /// * `ctx` - Project context (required for sandbox validation) + /// * `filters` - File type filters to show + /// * `default_name` - Optional default filename + /// + /// # Returns + /// + /// * `Ok(Some(path))` - User selected a valid location within project + /// * `Ok(None)` - User cancelled + /// * `Err(PortError::SandboxViolation)` - Selected location is outside project directory + /// * `Err(PortError::Unsupported)` - Dialogs not available + fn show_save_file_dialog( + &self, + ctx: &ProjectContext, + filters: &[FileFilter], + default_name: Option<&str>, + ) -> Result, PortError>; + + /// Show a "Select Folder" dialog for selecting a directory within the project. + /// + /// The selected folder must be within the project directory. If the user + /// selects a folder outside the project, returns `Err(PortError::SandboxViolation)`. + /// + /// # Arguments + /// + /// * `ctx` - Project context (required for sandbox validation) + /// + /// # Returns + /// + /// * `Ok(Some(path))` - User selected a valid folder within project + /// * `Ok(None)` - User cancelled + /// * `Err(PortError::SandboxViolation)` - Selected folder is outside project directory + /// * `Err(PortError::Unsupported)` - Dialogs not available + fn show_select_folder_dialog( + &self, + ctx: &ProjectContext, + ) -> Result, PortError>; + + /// Show a confirmation dialog with OK and Cancel buttons. + /// + /// # Arguments + /// + /// * `title` - Dialog title + /// * `message` - Dialog message + /// + /// # Returns + /// + /// * `Ok(true)` - User clicked OK + /// * `Ok(false)` - User clicked Cancel + /// * `Err(PortError::Unsupported)` - Dialogs not available + fn show_confirm_dialog(&self, title: &str, message: &str) -> Result; + + /// Show a message dialog with custom buttons. + /// + /// # Arguments + /// + /// * `title` - Dialog title + /// * `message` - Dialog message + /// * `buttons` - Button labels (2-3 buttons) + /// + /// # Returns + /// + /// * `Ok(Some(index))` - User clicked button at index (0-based) + /// * `Ok(None)` - User dismissed without selection + /// * `Err(PortError::Unsupported)` - Dialogs not available + fn show_message_dialog( + &self, + title: &str, + message: &str, + buttons: &[&str], + ) -> Result, PortError>; + + // === Clipboard === + + /// Read text from the clipboard. + /// + /// # Returns + /// + /// * `Ok(Some(text))` - Clipboard contains text + /// * `Ok(None)` - Clipboard is empty or doesn't contain text + /// * `Err(PortError::Unsupported)` - Clipboard not available + fn clipboard_read_text(&self) -> Result, PortError>; + + /// Write text to the clipboard. + /// + /// # Arguments + /// + /// * `text` - Text to write to clipboard + /// + /// # Errors + /// + /// * `Err(PortError::Unsupported)` - Clipboard not available + /// * `Err(PortError::Other)` - Failed to write to clipboard + fn clipboard_write_text(&self, text: &str) -> Result<(), PortError>; + + // === Logging === + + /// Log a message at the specified level. + /// + /// Messages are routed to the appropriate destination: + /// - Desktop: stderr or log file + /// - Web: console.log/console.error + /// - Mobile: NSLog/android.util.Log + fn log(&self, level: LogLevel, message: &str); + + // === Performance === + + /// Create a performance mark. + /// + /// Used for profiling and performance analysis. + fn performance_mark(&self, name: &str); + + /// Create a performance mark with additional details. + /// + /// # Arguments + /// + /// * `name` - Mark name + /// * `details` - Additional details (JSON string) + fn performance_mark_with_details(&self, name: &str, details: &str); + + // === Configuration === + + /// Get the configuration directory for storing app settings. + /// + /// Platform-specific locations: + /// - macOS: `~/Library/Application Support/net.nodebox.NodeBox/` + /// - Windows: `%APPDATA%\NodeBox\` + /// - Linux: `~/.config/nodebox/` + /// + /// # Errors + /// + /// * `Err(PortError::Unsupported)` - No config directory on this platform + fn get_config_dir(&self) -> Result; +} + +/// A minimal Port implementation for testing. +/// +/// This port returns `Unsupported` for most operations, making it suitable +/// for tests that don't need actual file or dialog operations. +pub struct TestPort; + +impl TestPort { + /// Create a new TestPort. + pub fn new() -> Self { + Self + } +} + +impl Default for TestPort { + fn default() -> Self { + Self::new() + } +} + +impl Port for TestPort { + 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, PortError> { + Err(PortError::Unsupported) + } + + fn write_file( + &self, + _ctx: &ProjectContext, + _path: &RelativePath, + _data: &[u8], + ) -> Result<(), PortError> { + Err(PortError::Unsupported) + } + + fn list_directory( + &self, + _ctx: &ProjectContext, + _path: &RelativePath, + ) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn read_text_file(&self, _ctx: &ProjectContext, _path: &str) -> Result { + Err(PortError::Unsupported) + } + + fn read_binary_file(&self, _ctx: &ProjectContext, _path: &str) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn load_app_resource(&self, _name: &str) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn read_project(&self, _ctx: &ProjectContext) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn write_project(&self, _ctx: &ProjectContext, _data: &[u8]) -> Result<(), PortError> { + Err(PortError::Unsupported) + } + + fn load_library(&self, _name: &str) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn http_get(&self, _url: &str) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn show_open_project_dialog( + &self, + _filters: &[FileFilter], + ) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn show_save_project_dialog( + &self, + _filters: &[FileFilter], + _default_name: Option<&str>, + ) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn show_open_file_dialog( + &self, + _ctx: &ProjectContext, + _filters: &[FileFilter], + ) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn show_save_file_dialog( + &self, + _ctx: &ProjectContext, + _filters: &[FileFilter], + _default_name: Option<&str>, + ) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn show_select_folder_dialog( + &self, + _ctx: &ProjectContext, + ) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn show_confirm_dialog(&self, _title: &str, _message: &str) -> Result { + Err(PortError::Unsupported) + } + + fn show_message_dialog( + &self, + _title: &str, + _message: &str, + _buttons: &[&str], + ) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn clipboard_read_text(&self) -> Result, PortError> { + Err(PortError::Unsupported) + } + + fn clipboard_write_text(&self, _text: &str) -> Result<(), PortError> { + Err(PortError::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(PortError::Unsupported) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relative_path_valid() { + // Simple paths should be 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() { + // Paths with ".." should be rejected + assert!(matches!( + RelativePath::new(".."), + Err(PortError::SandboxViolation) + )); + assert!(matches!( + RelativePath::new("../file.txt"), + Err(PortError::SandboxViolation) + )); + assert!(matches!( + RelativePath::new("subdir/../other.txt"), + Err(PortError::SandboxViolation) + )); + assert!(matches!( + RelativePath::new("a/b/../../c.txt"), + Err(PortError::SandboxViolation) + )); + } + + #[test] + fn test_relative_path_rejects_absolute() { + // Absolute paths should be rejected + assert!(matches!( + RelativePath::new("/etc/passwd"), + Err(PortError::SandboxViolation) + )); + assert!(matches!( + RelativePath::new("/home/user/file.txt"), + Err(PortError::SandboxViolation) + )); + } + + #[test] + fn test_relative_path_rejects_windows_absolute() { + // Windows-style absolute paths should be rejected + assert!(matches!( + RelativePath::new("C:/Users/file.txt"), + Err(PortError::SandboxViolation) + )); + assert!(matches!( + RelativePath::new("D:\\Documents\\file.txt"), + Err(PortError::SandboxViolation) + )); + } + + #[test] + fn test_relative_path_join() { + let base = RelativePath::new("subdir").unwrap(); + + // Valid joins + let joined = base.join("file.txt").unwrap(); + assert_eq!(joined.as_path(), Path::new("subdir/file.txt")); + + // Invalid joins (with ..) + assert!(matches!( + base.join("../escape.txt"), + Err(PortError::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!(PortError::from(not_found), PortError::NotFound)); + + let permission = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); + assert!(matches!( + PortError::from(permission), + PortError::PermissionDenied + )); + + let other = std::io::Error::new(std::io::ErrorKind::Other, "something else"); + assert!(matches!(PortError::from(other), PortError::IoError(_))); + } + + #[test] + fn test_port_error_display() { + assert_eq!( + format!("{}", PortError::Unsupported), + "operation not supported on this platform" + ); + assert_eq!(format!("{}", PortError::NotFound), "not found"); + assert_eq!(format!("{}", PortError::PermissionDenied), "permission denied"); + assert_eq!( + format!("{}", PortError::SandboxViolation), + "path escapes project sandbox" + ); + assert_eq!( + format!("{}", PortError::NetworkError("timeout".to_string())), + "network error: timeout" + ); + assert_eq!( + format!("{}", PortError::LibraryNotFound("math".to_string())), + "library not found: math" + ); + } + + #[test] + fn test_platform_info_current() { + let info = PlatformInfo::current(); + // Just verify it doesn't panic and returns something reasonable + assert!(!info.os_name.is_empty()); + } +} 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" From 0af384ba733de6001f13e46f4e8c75f65c35e345 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 22:01:23 +0100 Subject: [PATCH 013/100] Add server API spec --- docs/server-api.md | 458 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 docs/server-api.md diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 00000000..f3353f2a --- /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 | + +--- + +## WebPort Implementation Notes + +The JavaScript WebPort implementation maps Port trait methods to API calls: + +```javascript +const webPort = { + async read_file(ctx, path) { + const resp = await fetch(`/api/v1/projects/${ctx.project_id}/assets/${path}`); + if (!resp.ok) throw new PortError(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 PortError('IoError'); + }, + + async list_directory(ctx, path) { + const resp = await fetch(`/api/v1/projects/${ctx.project_id}/assets/${path}`); + if (!resp.ok) throw new PortError(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 PortError(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 PortError('IoError'); + }, + + async load_library(name) { + const resp = await fetch(`/api/v1/libraries/${name}`); + if (!resp.ok) throw new PortError(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 PortError('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 PortError('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 PortError('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 WebPort can provide a more native-like experience: + +```javascript +class FileSystemAccessPort { + 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 +} +``` From 371348e8a93f6c8b6d382d2270c3253f8767160c Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 2 Feb 2026 22:25:07 +0100 Subject: [PATCH 014/100] Always show points for point-output nodes in viewer Nodes that output points (like grid, scatter, makePoint) now always display their points in the viewer, even when the global "Show Points" toggle is off. Implementation uses a simple approach: check the rendered node's output_type directly at render time via NodeLibrary::is_rendered_output_point(), rather than threading a flag through the evaluation pipeline. Co-Authored-By: Claude Opus 4.5 --- crates/nodebox-core/src/node/library.rs | 29 +++++++++++++++++++++ crates/nodebox-gui/src/eval.rs | 34 ++++++++++++++++++++++--- crates/nodebox-gui/src/viewer_pane.rs | 6 ++--- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/nodebox-core/src/node/library.rs b/crates/nodebox-core/src/node/library.rs index b603df9e..59bb3381 100644 --- a/crates/nodebox-core/src/node/library.rs +++ b/crates/nodebox-core/src/node/library.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use super::Node; +use super::PortType; /// A library of nodes, typically loaded from an .ndbx file. /// @@ -123,6 +124,14 @@ 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) + } } #[cfg(test)] @@ -172,4 +181,24 @@ 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()); + } } diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 3483c6cb..9f858cbc 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -142,6 +142,11 @@ impl NodeOutput { _ => 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. @@ -265,10 +270,12 @@ pub fn evaluate_network_cancellable( } match result { - Ok(output) => EvalOutcome::Completed { - geometry: output.to_paths(), - errors: Vec::new(), - }, + Ok(output) => { + EvalOutcome::Completed { + geometry: output.to_paths(), + errors: Vec::new(), + } + } Err(EvalError::Cancelled) => EvalOutcome::Cancelled, Err(e) => { // Extract node name based on error type @@ -1925,6 +1932,25 @@ mod tests { 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] diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index 11471541..c3e70bde 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -695,8 +695,8 @@ impl ViewerPane { geometry_hash, ); - // Draw points overlay if enabled (still use egui for this) - if self.show_points { + // 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); } @@ -725,7 +725,7 @@ impl ViewerPane { for path in &state.geometry { self.draw_path(painter, path, center); - if self.show_points { + if self.show_points || state.library.is_rendered_output_point() { self.draw_points(painter, path, center); } } From f8cba136075db905773c5aecc47ec2aaf3ae2f65 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 9 Feb 2026 22:47:03 +0100 Subject: [PATCH 015/100] Fix angle directions to match Java version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arc swept counterclockwise instead of clockwise because segment angles were subtracted instead of added in the arc loop. Also fix origin translation order in rotate, scale, and skew filters — Transform::then() applies in opposite order from Java's AffineTransform.concatenate(), so the translate-to-origin and translate-back were swapped. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-ops/src/filters.rs | 12 ++++++------ crates/nodebox-ops/src/generators.rs | 6 +++--- crates/nodebox-ops/src/parallel.rs | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/nodebox-ops/src/filters.rs b/crates/nodebox-ops/src/filters.rs index 1bf12eac..be4040d1 100644 --- a/crates/nodebox-ops/src/filters.rs +++ b/crates/nodebox-ops/src/filters.rs @@ -417,9 +417,9 @@ pub fn to_points(path: &Path) -> Vec { /// 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) } @@ -441,9 +441,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) } @@ -482,9 +482,9 @@ pub fn translate(geometry: &Path, offset: Point) -> Path { /// 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) } diff --git a/crates/nodebox-ops/src/generators.rs b/crates/nodebox-ops/src/generators.rs index c5441987..7aa81375 100644 --- a/crates/nodebox-ops/src/generators.rs +++ b/crates/nodebox-ops/src/generators.rs @@ -212,7 +212,7 @@ pub fn arc(position: Point, width: f64, height: f64, start_angle: f64, degrees: // 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; + 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); } diff --git a/crates/nodebox-ops/src/parallel.rs b/crates/nodebox-ops/src/parallel.rs index 84204372..f055ec30 100644 --- a/crates/nodebox-ops/src/parallel.rs +++ b/crates/nodebox-ops/src/parallel.rs @@ -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() } From a19783ce50a5229bdb6d91eb0ac1a73c149d1512 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 9 Feb 2026 22:50:09 +0100 Subject: [PATCH 016/100] Remove start_angle negation from arc The negation was a leftover from Java's Arc2D (Y-up convention). Since Rust computes screen coordinates directly (Y-down), the negation caused start_angle to point in the wrong direction (e.g. up instead of down for 90 degrees). Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-ops/src/generators.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-ops/src/generators.rs b/crates/nodebox-ops/src/generators.rs index 7aa81375..fc410947 100644 --- a/crates/nodebox-ops/src/generators.rs +++ b/crates/nodebox-ops/src/generators.rs @@ -210,8 +210,8 @@ 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; + // 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(); From 567c2bf3dbfe29c34eae0f518cb41c40f73a71e9 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 9 Feb 2026 23:03:49 +0100 Subject: [PATCH 017/100] Fix viewer empty on startup by triggering initial render render_pending was initialized to false, so the render worker was never dispatched on the first frame, leaving the viewer blank until a parameter change. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 51a4711e..7d8c305c 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -113,7 +113,7 @@ impl NodeBoxApp { previous_library_hash: hash, render_worker: RenderWorkerHandle::spawn(), render_state: RenderState::new(), - render_pending: false, // Initial geometry is already evaluated in AppState::new() + render_pending: true, native_menu, recent_files, } @@ -193,7 +193,7 @@ impl NodeBoxApp { previous_library_hash: hash, render_worker: RenderWorkerHandle::spawn(), render_state: RenderState::new(), - render_pending: false, // Initial geometry is already evaluated in AppState::new() + render_pending: true, native_menu, recent_files, } From f5e69945d22cdcc2451d4c48bca6526ad31565f7 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Mon, 9 Feb 2026 23:14:01 +0100 Subject: [PATCH 018/100] Fix point numbers showing "0" for grid/list outputs Track a global point index across all paths in draw_point_numbers() so that points from separate Path objects (as produced by grid/list nodes) get incrementing numbers instead of always resetting to 0. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/viewer_pane.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index c3e70bde..c4040a47 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -565,8 +565,9 @@ impl ViewerPane { // Draw point numbers on top of everything (including handles) if self.show_point_numbers { + let mut point_index = 0usize; for path in &state.geometry { - self.draw_point_numbers(&painter, path, center); + point_index = self.draw_point_numbers(&painter, path, center, point_index); } } @@ -841,12 +842,14 @@ impl ViewerPane { } /// 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) { + /// 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 (i, pp) in contour.points.iter().enumerate() { + 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); @@ -855,7 +858,7 @@ impl ViewerPane { let y = screen_pt.y + 2.0; // Draw each digit of the number - let num_str = i.to_string(); + 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) { @@ -873,8 +876,10 @@ impl ViewerPane { } } } + point_index += 1; } } + point_index } /// Draw a path on the canvas. From c4944f5f21ed38b5387a4c2d72ccbe7a512ab779 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Tue, 10 Feb 2026 14:23:30 +0100 Subject: [PATCH 019/100] Fix resample node to support "by length" method The resample node was ignoring the method parameter and always using "by amount". Now it reads the method and dispatches to resample_by_length or resample_by_amount accordingly. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/eval.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 9f858cbc..bee631ab 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -912,8 +912,14 @@ fn execute_node( // Resample "corevector.resample" => { let shape = require_path(inputs, node_name, "shape")?; - let points = get_int(inputs, "points", 20) as usize; - let path = nodebox_ops::resample(&shape, points); + let method = get_string(inputs, "method", "length"); + let path = if method == "length" { + let length = get_float(inputs, "length", 10.0); + nodebox_ops::resample_by_length(&shape, length) + } else { + let points = get_int(inputs, "points", 20) as usize; + nodebox_ops::resample(&shape, points) + }; Ok(NodeOutput::Path(path)) } From 97b2e68c2f282250fff1368ccec7830636cc8ef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 13:05:47 +0000 Subject: [PATCH 020/100] Implement dispatch for 87 missing nodes: math, string, list, color, core, network, data Major changes to close the gap between Java and Rust node implementations: **Phase 0 - NodeOutput extensions:** - Add Floats, Ints, Strings, Booleans list variants to NodeOutput enum - Update to_value_list(), list_len(), collect_results() for typed list handling - Add get_floats(), get_strings(), get_booleans() helper functions **Phase 1 - Dispatch wiring (80+ nodes, ops already existed):** - Math (41 nodes): arithmetic, trig, comparison, aggregation, number generators - String (21 nodes): manipulation, testing, encoding, list-returning operations - List (18 nodes): polymorphic operations on any list type (Paths/Points/Floats/etc) - Color (4 nodes): rgb_color, hsb_color, gray_color, color pass-through - Geometry: shape_on_path dispatch, explicit null/doNothing handler **Phase 2 - Core & network:** - core.frame: Add frame field to ProjectContext, wire from animation bar - network.http_get: Wire existing Port::http_get() - network.encode_url: Simple percent-encoding implementation **Phase 3 - Data nodes:** - data.import_text: File reading via Port system - data.import_csv: Basic CSV parsing with delimiter support Also updates populate_default_ports() in state.rs and NODE_TEMPLATES in node_library.rs to support all new node types in the UI. Deferred: device nodes (8), complex geometry (textpath, compound, distribute, round_segments, text_on_path), and full JSON query support. https://claude.ai/code/session_01Gq7eQVRgsmpwbD9Urzne7z --- crates/nodebox-gui/src/app.rs | 2 + crates/nodebox-gui/src/eval.rs | 839 ++++++++++++++++++++++++- crates/nodebox-gui/src/node_library.rs | 282 ++++++++- crates/nodebox-gui/src/state.rs | 283 ++++++++- crates/nodebox-port/src/lib.rs | 4 + 5 files changed, 1398 insertions(+), 12 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 7d8c305c..fcdccb27 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -412,6 +412,8 @@ impl NodeBoxApp { // 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, self.state.library.clone(), diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index bee631ab..4f001c44 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -57,14 +57,22 @@ pub enum NodeOutput { 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 boolean value. Boolean(bool), + /// A list of boolean values. + Booleans(Vec), } impl NodeOutput { @@ -129,6 +137,10 @@ impl NodeOutput { 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(), v => vec![v.clone()], // Single values remain single } } @@ -138,6 +150,10 @@ impl NodeOutput { 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::None => 0, _ => 1, } @@ -382,15 +398,57 @@ fn collect_results(results: Vec) -> NodeOutput { 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) + // 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) + } + _ => { + // 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) + } + } } } @@ -723,10 +781,41 @@ fn get_bool(inputs: &HashMap, name: &str, default: bool) -> 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 string values from input. +fn get_strings(inputs: &HashMap, name: &str) -> Vec { + match inputs.get(name) { + Some(NodeOutput::Strings(ss)) => ss.clone(), + Some(NodeOutput::String(s)) => vec![s.clone()], + _ => 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(), + } +} + /// 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) { @@ -1172,6 +1261,738 @@ fn execute_node( } } + // ======================== + // 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 = nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_ops::math::modulo(v1, v2))) + } + + // Unary math + "math.negate" => { + Ok(NodeOutput::Float(nodebox_ops::math::negate(get_float(inputs, "value", 0.0)))) + } + "math.abs" => { + Ok(NodeOutput::Float(nodebox_ops::math::abs(get_float(inputs, "value", 0.0)))) + } + "math.sqrt" => { + Ok(NodeOutput::Float(nodebox_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(nodebox_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(nodebox_ops::math::log(v))) + } + + // Rounding + "math.ceil" => { + Ok(NodeOutput::Float(nodebox_ops::math::ceil(get_float(inputs, "value", 0.0)))) + } + "math.floor" => { + Ok(NodeOutput::Float(nodebox_ops::math::floor(get_float(inputs, "value", 0.0)))) + } + "math.round" => { + Ok(NodeOutput::Int(nodebox_ops::math::round(get_float(inputs, "value", 0.0)))) + } + + // Trigonometry + "math.sin" => { + Ok(NodeOutput::Float(nodebox_ops::math::sin(get_float(inputs, "value", 0.0)))) + } + "math.cos" => { + Ok(NodeOutput::Float(nodebox_ops::math::cos(get_float(inputs, "value", 0.0)))) + } + "math.radians" => { + Ok(NodeOutput::Float(nodebox_ops::math::radians(get_float(inputs, "degrees", 0.0)))) + } + "math.degrees" => { + Ok(NodeOutput::Float(nodebox_ops::math::degrees(get_float(inputs, "radians", 0.0)))) + } + + // Constants + "math.pi" => { + Ok(NodeOutput::Float(nodebox_ops::math::pi())) + } + "math.e" => { + Ok(NodeOutput::Float(nodebox_ops::math::e())) + } + + // Predicates + "math.even" => { + Ok(NodeOutput::Boolean(nodebox_ops::math::even(get_float(inputs, "value", 0.0)))) + } + "math.odd" => { + Ok(NodeOutput::Boolean(nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_ops::math::reflect(p1, p2, angle, distance))) + } + + // Aggregation + "math.sum" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(nodebox_ops::math::sum(&values))) + } + "math.average" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(nodebox_ops::math::average(&values))) + } + "math.max" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(nodebox_ops::math::max(&values))) + } + "math.min" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Float(nodebox_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 = nodebox_ops::math::OverflowMethod::from_str(&method); + Ok(NodeOutput::Float(nodebox_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 = nodebox_ops::math::WaveType::from_str(&wave_type_str); + Ok(NodeOutput::Float(nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_ops::math::range(start, end, step))) + } + "math.running_total" => { + let values = get_floats(inputs, "values"); + Ok(NodeOutput::Floats(nodebox_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(nodebox_ops::string::length(&s) as i64)) + } + "string.word_count" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::Int(nodebox_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(nodebox_ops::string::concatenate(&parts))) + } + "string.change_case" => { + let s = get_string(inputs, "string", ""); + let method = get_string(inputs, "method", "uppercase"); + let case_method = nodebox_ops::string::CaseMethod::from_str(&method); + Ok(NodeOutput::String(nodebox_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(nodebox_ops::string::format_number(value, &format))) + } + "string.trim" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::String(nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_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(nodebox_ops::string::contains(&s, &value))) + } + "string.ends_with" => { + let s = get_string(inputs, "string", ""); + let value = get_string(inputs, "ends_with", ""); + Ok(NodeOutput::Boolean(nodebox_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(nodebox_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(nodebox_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(nodebox_ops::string::make_strings(&s, &separator))) + } + "string.characters" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::Strings(nodebox_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(nodebox_ops::string::random_character(&chars, amount, seed))) + } + "string.as_binary_list" => { + let s = get_string(inputs, "string", ""); + Ok(NodeOutput::Strings(nodebox_ops::string::as_binary_list(&s))) + } + "string.as_number_list" => { + let s = get_string(inputs, "string", ""); + let radix = get_int(inputs, "radix", 10) as u32; + let padding = get_bool(inputs, "padding", true); + Ok(NodeOutput::Strings(nodebox_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)), + _ => 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)), + _ => 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)), + _ => 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)), + _ => Ok(NodeOutput::None), + } + } + + "list.rest" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(nodebox_ops::list::rest(v))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::rest(v))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::rest(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::rest(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::rest(v))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::rest(v))), + _ => Ok(NodeOutput::None), + } + } + + "list.reverse" => { + match inputs.get("list") { + Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(nodebox_ops::list::reverse(v))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::reverse(v))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::reverse(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::reverse(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::reverse(v))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_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(nodebox_ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_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(nodebox_ops::list::shift(v, amount))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::shift(v, amount))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::shift(v, amount))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::shift(v, amount))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::shift(v, amount))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_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(nodebox_ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::repeat(v, amount, per_item))), + _ => Ok(NodeOutput::None), + } + } + + "list.sort" => { + match inputs.get("list") { + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::sort_floats(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::sort(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_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(nodebox_ops::list::shuffle(v, seed))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::shuffle(v, seed))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::shuffle(v, seed))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::shuffle(v, seed))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::shuffle(v, seed))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_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(nodebox_ops::list::pick(v, amount, seed))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::pick(v, amount, seed))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::pick(v, amount, seed))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::pick(v, amount, seed))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::pick(v, amount, seed))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_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(nodebox_ops::list::cull(v, &booleans))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::cull(v, &booleans))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::cull(v, &booleans))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::cull(v, &booleans))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::cull(v, &booleans))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_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(nodebox_ops::list::take_every(v, n))), + Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::take_every(v, n))), + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::take_every(v, n))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::take_every(v, n))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::take_every(v, n))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::take_every(v, n))), + _ => Ok(NodeOutput::None), + } + } + + "list.distinct" => { + match inputs.get("list") { + Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::distinct_floats(v))), + Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::distinct(v))), + Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::distinct(v))), + Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_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" | "list.zip_map" => { + // These require Map support, return None for now + log::warn!("Map-based list node not yet fully supported: {}", proto); + Ok(NodeOutput::None) + } + + // ======================== + // 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::Strings(Vec::new())); + } + match port.read_text_file(project_context, &file_path) { + Ok(content) => { + let delimiter = match get_string(inputs, "delimiter", "comma").as_str() { + "semicolon" => ';', + "colon" => ':', + "tab" => '\t', + "space" => ' ', + _ => ',', + }; + // Simple CSV parsing: split by delimiter, return as list of strings + let lines: Vec = content.lines() + .map(|line| { + line.split(delimiter) + .map(|field| field.trim().to_string()) + .collect::>() + .join("\t") + }) + .collect(); + Ok(NodeOutput::Strings(lines)) + } + Err(e) => { + log::warn!("Import CSV error: {}", e); + Ok(NodeOutput::Strings(Vec::new())) + } + } + } + "data.lookup" | "data.filter_data" | "data.make_table" => { + // These require Map/table support + log::warn!("Data node not yet fully supported: {}", proto); + Ok(NodeOutput::None) + } + + "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 diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 3943f1a6..d072e55d 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -132,6 +132,149 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "geometry", description: "Import an SVG file as geometry", }, + // Math nodes + NodeTemplate { + name: "number", + prototype: "math.number", + category: "math", + description: "Create a number 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: "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: "compare", + prototype: "math.compare", + category: "math", + description: "Compare two values", + }, + 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", + }, + // 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", + }, + // 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: "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", + }, + // Color nodes + 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", + }, + // Core nodes + NodeTemplate { + name: "frame", + prototype: "core.frame", + category: "core", + description: "Get the current animation frame", + }, ]; /// The node library browser widget. @@ -167,7 +310,7 @@ impl NodeLibraryBrowser { // Category filter buttons ui.horizontal_wrapped(|ui| { - let categories = ["geometry", "transform", "color"]; + let categories = ["geometry", "transform", "color", "math", "string", "list", "core"]; for cat in categories { let is_selected = self.selected_category.as_deref() == Some(cat); if ui.selectable_label(is_selected, cat).clicked() { @@ -237,9 +380,11 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, 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(format!("corevector/{}", template.name)) + .with_function(function) .with_category(template.category) .with_position(position.x, position.y); @@ -379,6 +524,139 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::boolean("centered", true)) .with_input(Port::point("position", Point::ZERO)); } + // Math nodes + "number" => { + node = node.with_input(Port::float("value", 0.0)); + } + "add" | "subtract" | "multiply" | "divide" => { + node = node + .with_input(Port::float("value1", 0.0)) + .with_input(Port::float("value2", 0.0)); + } + "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); + } + "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"), + ])); + } + "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"), + ])); + } + "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"), + ])); + } + // String nodes + "string" => { + node = node.with_input(Port::string("value", "")); + } + "concatenate" => { + node = node + .with_input(Port::string("string1", "")) + .with_input(Port::string("string2", "")) + .with_input(Port::string("string3", "")) + .with_input(Port::string("string4", "")); + } + "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); + } + // List nodes + "count" | "first" | "reverse" | "shuffle" | "slice" => { + node = node.with_input(Port::geometry("list").with_port_range(PortRange::List)); + match template.name { + "shuffle" => { node = node.with_input(Port::int("seed", 0)); } + "slice" => { + node = node + .with_input(Port::int("start_index", 0)) + .with_input(Port::int("size", 10)) + .with_input(Port::boolean("invert", false)); + } + _ => {} + } + } + // Color nodes + "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)); + } + "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)); + } + "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)); + } + // Core nodes + "frame" => { + // No input ports; outputs the current frame number + } _ => {} } diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index 11cf6723..bbef605b 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use nodebox_core::geometry::{Path as GeoPath, Color}; +use nodebox_core::geometry::{Path as GeoPath, Color, Point}; use nodebox_core::node::{Node, NodeLibrary, MenuItem, Port, PortRange, Widget}; /// The main application state. @@ -409,6 +409,287 @@ pub fn populate_default_ports(node: &mut Node) { 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)); + } + _ => {} } } diff --git a/crates/nodebox-port/src/lib.rs b/crates/nodebox-port/src/lib.rs index d47d6152..53dd1a43 100644 --- a/crates/nodebox-port/src/lib.rs +++ b/crates/nodebox-port/src/lib.rs @@ -151,6 +151,8 @@ pub struct ProjectContext { /// 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 { @@ -159,6 +161,7 @@ impl ProjectContext { Self { root: None, project_file: None, + frame: 1, } } @@ -167,6 +170,7 @@ impl ProjectContext { Self { root: Some(root.into()), project_file: Some(project_file.into()), + frame: 1, } } From 57b2579a1652f9f2d5d8aa08631e8dc26cf4f141 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 14:49:37 +0000 Subject: [PATCH 021/100] Implement data viewer spreadsheet replacing placeholder Replace the "coming soon" placeholder in the Data tab with a proper spreadsheet-style table using egui_extras::TableBuilder. Adds two view modes toggled by a segmented control: - Paths view: shows fill, stroke, stroke width, contour/point counts - Points view: shows x, y, type, path index, contour index per point Features zebra striping, color swatches with hex values, resizable columns, and virtual scrolling for large geometry sets. https://claude.ai/code/session_01TEifeTS1fnkrPJwWZ758cc --- crates/nodebox-gui/src/theme.rs | 17 ++ crates/nodebox-gui/src/viewer_pane.rs | 418 ++++++++++++++++++++++++-- 2 files changed, 409 insertions(+), 26 deletions(-) diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 42bcf2d6..6b7a5761 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -170,6 +170,23 @@ 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 = SLATE_900; +/// Zebra stripe: odd row alternating background (between SLATE_900 and SLATE_800) +pub const TABLE_ROW_ODD: Color32 = Color32::from_rgb(19, 29, 50); +/// Table header background +pub const TABLE_HEADER_BG: Color32 = SLATE_800; +/// Table header text color +pub const TABLE_HEADER_TEXT: Color32 = SLATE_300; +/// Table cell text color +pub const TABLE_CELL_TEXT: Color32 = SLATE_200; +/// Index column text color (subdued) +pub const TABLE_INDEX_TEXT: Color32 = SLATE_400; + // ============================================================================= // PANE HEADER COLORS // ============================================================================= diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index c4040a47..a60b838c 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -1,7 +1,8 @@ //! 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 egui_extras::{Column, TableBuilder}; +use nodebox_core::geometry::{Color, Path, PathPoint, Point, PointType}; use crate::components; use crate::handles::{FourPointHandle, HandleSet, HANDLE_COLOR}; use crate::pan_zoom::PanZoom; @@ -40,6 +41,13 @@ pub enum ViewerTab { 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. @@ -224,6 +232,8 @@ pub struct ViewerPane { /// 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, } impl Default for ViewerPane { @@ -253,6 +263,7 @@ impl ViewerPane { vello_viewer: VelloViewer::new(), #[cfg(feature = "gpu-rendering")] use_gpu_rendering: true, // Default to GPU rendering when available + data_view_mode: DataViewMode::Points, } } @@ -732,42 +743,397 @@ impl ViewerPane { } } - /// Show the data view (placeholder for now). + /// Show the data view with spreadsheet table. fn show_data_view(&mut self, ui: &mut egui::Ui, state: &AppState) { + // Sub-header bar with view mode toggle and stats + self.show_data_view_header(ui, state); + + // Table body + 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), + } + } + + /// Show the data view sub-header with mode toggle and stats. + fn show_data_view_header(&mut self, ui: &mut egui::Ui, state: &AppState) { + let header_height = 28.0; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), header_height), + egui::Sense::hover(), + ); + + // Background + ui.painter().rect_filled(rect, 0.0, theme::SLATE_800); + + // Bottom border + 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::SLATE_950), + ); + + // Segmented control for Paths/Points + let selected_index = if self.data_view_mode == DataViewMode::Paths { 0 } else { 1 }; + let (clicked_index, _x) = components::header_segmented_control( + ui, + rect, + rect.left() + theme::PADDING, + ["Paths", "Points"], + selected_index, + ); + if let Some(index) = clicked_index { + self.data_view_mode = if index == 0 { DataViewMode::Paths } else { DataViewMode::Points }; + } + + // Stats on the right side + let total_paths = state.geometry.len(); + let total_points: usize = state.geometry.iter() + .flat_map(|p| &p.contours) + .map(|c| c.points.len()) + .sum(); + let stats_text = format!("{} paths, {} points", total_paths, total_points); + ui.painter().text( + egui::pos2(rect.right() - theme::PADDING, rect.center().y), + egui::Align2::RIGHT_CENTER, + &stats_text, + egui::FontId::proportional(10.0), + theme::TEXT_DISABLED, + ); + } + + /// Show empty state when no geometry 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("Data View") + egui::RichText::new("No data to display") .color(theme::TEXT_DISABLED) - .size(16.0), + .size(14.0), ); - ui.add_space(10.0); + ui.add_space(8.0); ui.label( - egui::RichText::new("Tabular view of geometry data coming soon.") + egui::RichText::new("Render a node to see its geometry data here.") .color(theme::TEXT_DISABLED) - .size(12.0), + .size(11.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), - ); + /// 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.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), + ); + }); + }); + }); + }); + } - 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), - ); - }); + /// 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.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::SLATE_600), + 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). From c0d86e08d44cb0aacc130d43eab01738ad88df82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 14:49:37 +0000 Subject: [PATCH 022/100] Implement data viewer spreadsheet replacing placeholder Replace the "coming soon" placeholder in the Data tab with a proper spreadsheet-style table using egui_extras::TableBuilder. Adds two view modes toggled by a segmented control: - Paths view: shows fill, stroke, stroke width, contour/point counts - Points view: shows x, y, type, path index, contour index per point Features zebra striping, color swatches with hex values, resizable columns, and virtual scrolling for large geometry sets. https://claude.ai/code/session_01TEifeTS1fnkrPJwWZ758cc --- crates/nodebox-gui/src/theme.rs | 17 ++ crates/nodebox-gui/src/viewer_pane.rs | 418 ++++++++++++++++++++++++-- 2 files changed, 409 insertions(+), 26 deletions(-) diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 42bcf2d6..6b7a5761 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -170,6 +170,23 @@ 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 = SLATE_900; +/// Zebra stripe: odd row alternating background (between SLATE_900 and SLATE_800) +pub const TABLE_ROW_ODD: Color32 = Color32::from_rgb(19, 29, 50); +/// Table header background +pub const TABLE_HEADER_BG: Color32 = SLATE_800; +/// Table header text color +pub const TABLE_HEADER_TEXT: Color32 = SLATE_300; +/// Table cell text color +pub const TABLE_CELL_TEXT: Color32 = SLATE_200; +/// Index column text color (subdued) +pub const TABLE_INDEX_TEXT: Color32 = SLATE_400; + // ============================================================================= // PANE HEADER COLORS // ============================================================================= diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index c4040a47..a60b838c 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -1,7 +1,8 @@ //! 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 egui_extras::{Column, TableBuilder}; +use nodebox_core::geometry::{Color, Path, PathPoint, Point, PointType}; use crate::components; use crate::handles::{FourPointHandle, HandleSet, HANDLE_COLOR}; use crate::pan_zoom::PanZoom; @@ -40,6 +41,13 @@ pub enum ViewerTab { 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. @@ -224,6 +232,8 @@ pub struct ViewerPane { /// 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, } impl Default for ViewerPane { @@ -253,6 +263,7 @@ impl ViewerPane { vello_viewer: VelloViewer::new(), #[cfg(feature = "gpu-rendering")] use_gpu_rendering: true, // Default to GPU rendering when available + data_view_mode: DataViewMode::Points, } } @@ -732,42 +743,397 @@ impl ViewerPane { } } - /// Show the data view (placeholder for now). + /// Show the data view with spreadsheet table. fn show_data_view(&mut self, ui: &mut egui::Ui, state: &AppState) { + // Sub-header bar with view mode toggle and stats + self.show_data_view_header(ui, state); + + // Table body + 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), + } + } + + /// Show the data view sub-header with mode toggle and stats. + fn show_data_view_header(&mut self, ui: &mut egui::Ui, state: &AppState) { + let header_height = 28.0; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), header_height), + egui::Sense::hover(), + ); + + // Background + ui.painter().rect_filled(rect, 0.0, theme::SLATE_800); + + // Bottom border + 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::SLATE_950), + ); + + // Segmented control for Paths/Points + let selected_index = if self.data_view_mode == DataViewMode::Paths { 0 } else { 1 }; + let (clicked_index, _x) = components::header_segmented_control( + ui, + rect, + rect.left() + theme::PADDING, + ["Paths", "Points"], + selected_index, + ); + if let Some(index) = clicked_index { + self.data_view_mode = if index == 0 { DataViewMode::Paths } else { DataViewMode::Points }; + } + + // Stats on the right side + let total_paths = state.geometry.len(); + let total_points: usize = state.geometry.iter() + .flat_map(|p| &p.contours) + .map(|c| c.points.len()) + .sum(); + let stats_text = format!("{} paths, {} points", total_paths, total_points); + ui.painter().text( + egui::pos2(rect.right() - theme::PADDING, rect.center().y), + egui::Align2::RIGHT_CENTER, + &stats_text, + egui::FontId::proportional(10.0), + theme::TEXT_DISABLED, + ); + } + + /// Show empty state when no geometry 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("Data View") + egui::RichText::new("No data to display") .color(theme::TEXT_DISABLED) - .size(16.0), + .size(14.0), ); - ui.add_space(10.0); + ui.add_space(8.0); ui.label( - egui::RichText::new("Tabular view of geometry data coming soon.") + egui::RichText::new("Render a node to see its geometry data here.") .color(theme::TEXT_DISABLED) - .size(12.0), + .size(11.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), - ); + /// 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.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), + ); + }); + }); + }); + }); + } - 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), - ); - }); + /// 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.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::SLATE_600), + 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). From 8bc4b06b309ad6aed5370319027a498d50a63654 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 18:13:00 +0100 Subject: [PATCH 023/100] Auto-switch viewer to Data tab for non-geometry nodes - Add is_rendered_output_geometry() to detect visual node types - Auto-switch to Data tab when rendering non-geometry nodes (math, string, etc.) - Disable Visual tab and hide toolbar buttons for non-geometry nodes - Restore user's preferred tab when switching back to geometry nodes - Thread raw NodeOutput through eval pipeline to AppState for data display - Add values table for non-geometry data (floats, ints, strings, booleans) - Show color swatches in data viewer for color node outputs - Move Paths/Points toggle and type label into main header bar - Add disabled_index support to header_segmented_control - Add 8px left padding to all data table headers Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-core/src/node/library.rs | 34 ++ crates/nodebox-gui/src/app.rs | 6 +- crates/nodebox-gui/src/components.rs | 48 ++- crates/nodebox-gui/src/eval.rs | 155 +++++-- crates/nodebox-gui/src/render_worker.rs | 4 +- crates/nodebox-gui/src/state.rs | 7 + crates/nodebox-gui/src/viewer_pane.rs | 380 ++++++++++++------ .../nodebox-gui/tests/cancellation_tests.rs | 8 +- crates/nodebox-gui/tests/file_tests.rs | 16 +- 9 files changed, 467 insertions(+), 191 deletions(-) diff --git a/crates/nodebox-core/src/node/library.rs b/crates/nodebox-core/src/node/library.rs index 59bb3381..1a4f80b6 100644 --- a/crates/nodebox-core/src/node/library.rs +++ b/crates/nodebox-core/src/node/library.rs @@ -132,6 +132,14 @@ impl NodeLibrary { .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)] @@ -201,4 +209,30 @@ mod tests { .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-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index fcdccb27..e1550aa0 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -302,13 +302,14 @@ impl NodeBoxApp { #[cfg(test)] #[allow(dead_code)] pub fn evaluate_for_testing(&mut self) { - let (geometry, errors) = crate::eval::evaluate_network( + 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 @@ -377,11 +378,12 @@ impl NodeBoxApp { // Check for completed renders while let Some(result) = self.render_worker.try_recv_result() { match result { - RenderResult::Success { id, geometry, errors } => { + 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 diff --git a/crates/nodebox-gui/src/components.rs b/crates/nodebox-gui/src/components.rs index 445caedb..533c61ae 100644 --- a/crates/nodebox-gui/src/components.rs +++ b/crates/nodebox-gui/src/components.rs @@ -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::SLATE_700, + ); + } // 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 diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 4f001c44..faedc90c 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -36,6 +36,7 @@ pub enum EvalOutcome { /// Evaluation completed successfully (may include errors). Completed { geometry: Vec, + output: NodeOutput, errors: Vec, }, /// Evaluation was cancelled before completion. @@ -129,6 +130,72 @@ impl NodeOutput { } } + /// 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(_)) + } + + /// 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::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(), + } + } + + /// 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(_) => "color", + NodeOutput::Point(_) | NodeOutput::Points(_) => "point", + NodeOutput::Path(_) | NodeOutput::Paths(_) => "path", + } + } + + /// 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(), + _ => 1, + } + } + + /// Returns true if this output is a color type. + pub fn is_color(&self) -> bool { + matches!(self, NodeOutput::Color(_)) + } + + /// 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), + _ => None, + } + } + /// Convert any output to a list of individual values for list matching. fn to_value_list(&self) -> Vec { match self { @@ -174,7 +241,7 @@ pub fn evaluate_network( library: &NodeLibrary, port: &Arc, project_context: &ProjectContext, -) -> (Vec, Vec) { +) -> (Vec, NodeOutput, Vec) { let network = &library.root; // Find the rendered child @@ -182,7 +249,7 @@ pub fn evaluate_network( Some(name) => name.clone(), None => { // No rendered child, return empty - return (Vec::new(), Vec::new()); + return (Vec::new(), NodeOutput::None, Vec::new()); } }; @@ -193,7 +260,10 @@ pub fn evaluate_network( let result = evaluate_node(network, &rendered_name, &mut cache, port, project_context); match result { - Ok(output) => (output.to_paths(), Vec::new()), + 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 { @@ -215,7 +285,7 @@ pub fn evaluate_network( } _ => (rendered_name.clone(), e.to_string()), }; - (Vec::new(), vec![NodeError::new(node_name, message)]) + (Vec::new(), NodeOutput::None, vec![NodeError::new(node_name, message)]) } } } @@ -247,6 +317,7 @@ pub fn evaluate_network_cancellable( // No rendered child, return empty return EvalOutcome::Completed { geometry: Vec::new(), + output: NodeOutput::None, errors: Vec::new(), }; } @@ -289,6 +360,7 @@ pub fn evaluate_network_cancellable( Ok(output) => { EvalOutcome::Completed { geometry: output.to_paths(), + output, errors: Vec::new(), } } @@ -316,6 +388,7 @@ pub fn evaluate_network_cancellable( }; EvalOutcome::Completed { geometry: Vec::new(), + output: NodeOutput::None, errors: vec![NodeError::new(node_name, message)], } } @@ -2033,7 +2106,7 @@ mod tests { .with_rendered_child("ellipse1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2064,7 +2137,7 @@ mod tests { .with_rendered_child("colorize1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); // Check that the colorize was applied @@ -2103,7 +2176,7 @@ mod tests { .with_rendered_child("merge1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); // Merge collects all connected shapes assert_eq!(paths.len(), 2); } @@ -2122,7 +2195,7 @@ mod tests { .with_rendered_child("rect1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2144,7 +2217,7 @@ mod tests { .with_rendered_child("line1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2167,7 +2240,7 @@ mod tests { .with_rendered_child("polygon1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + 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) @@ -2191,7 +2264,7 @@ mod tests { .with_rendered_child("star1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); // Star with outer radius 50 should have bounds approximately 100x100 @@ -2216,7 +2289,7 @@ mod tests { .with_rendered_child("arc1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); } @@ -2241,7 +2314,7 @@ mod tests { .with_rendered_child("translate1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2275,7 +2348,7 @@ mod tests { .with_rendered_child("scale1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2309,7 +2382,7 @@ mod tests { .with_rendered_child("copy1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); // Should have 3 copies assert_eq!(paths.len(), 3); } @@ -2318,7 +2391,7 @@ mod tests { fn test_evaluate_empty_network() { let library = NodeLibrary::new("test"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2336,7 +2409,7 @@ mod tests { // No rendered_child set let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2356,7 +2429,7 @@ mod tests { // Should handle missing input gracefully let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2372,7 +2445,7 @@ mod tests { // Should handle unknown node type gracefully let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2397,7 +2470,7 @@ mod tests { .with_rendered_child("resample1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + 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 @@ -2427,7 +2500,7 @@ mod tests { .with_rendered_child("connect1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); } @@ -2451,7 +2524,7 @@ mod tests { .with_rendered_child("ellipse1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2478,7 +2551,7 @@ mod tests { .with_rendered_child("rect1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2504,7 +2577,7 @@ mod tests { .with_rendered_child("rect1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + 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 } @@ -2525,7 +2598,7 @@ mod tests { .with_rendered_child("polygon1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2551,7 +2624,7 @@ mod tests { .with_rendered_child("star1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2580,7 +2653,7 @@ mod tests { .with_rendered_child("arc1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2616,7 +2689,7 @@ mod tests { .with_rendered_child("copy1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + 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 @@ -2654,7 +2727,7 @@ mod tests { .with_rendered_child("connect1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); let bounds = paths[0].bounds().unwrap(); @@ -2688,7 +2761,7 @@ mod tests { .with_rendered_child("wiggle1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(!paths.is_empty(), "Wiggle should produce output"); } @@ -2718,7 +2791,7 @@ mod tests { .with_rendered_child("fit1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + 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 @@ -2821,7 +2894,7 @@ mod tests { .with_rendered_child("combine1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( paths.len(), @@ -2892,7 +2965,7 @@ mod tests { .with_rendered_child("combine1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( paths.len(), @@ -2930,7 +3003,7 @@ mod tests { .with_rendered_child("colorize1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( paths.len(), @@ -2971,7 +3044,7 @@ mod tests { .with_rendered_child("combine1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + 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 @@ -3010,7 +3083,7 @@ mod tests { .with_rendered_child("rect1"); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); // THE KEY ASSERTION: Must produce 100 rectangles, not 1! assert_eq!( @@ -3039,7 +3112,7 @@ mod tests { .with_rendered_child("colorize1"); let (port, ctx) = test_port_and_context(); - let (paths, errors) = evaluate_network(&library, &port, &ctx); + 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()); @@ -3074,7 +3147,7 @@ mod tests { .with_rendered_child("translate1"); let (port, ctx) = test_port_and_context(); - let (paths, errors) = evaluate_network(&library, &port, &ctx); + 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"); @@ -3097,7 +3170,7 @@ mod tests { .with_rendered_child("ellipse1"); let (port, ctx) = test_port_and_context(); - let (paths, errors) = evaluate_network(&library, &port, &ctx); + let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); // Should have output assert!(!paths.is_empty(), "Should have output for valid network"); @@ -3119,7 +3192,7 @@ mod tests { .with_rendered_child("my_colorize_node"); let (port, ctx) = test_port_and_context(); - let (_paths, errors) = evaluate_network(&library, &port, &ctx); + let (_paths, _output, errors) = evaluate_network(&library, &port, &ctx); assert!(!errors.is_empty(), "Should have an error"); assert_eq!( @@ -3142,7 +3215,7 @@ mod tests { .with_rendered_child("ellipse1"); let (port, ctx) = test_port_and_context(); - let (paths, errors) = evaluate_network(&library, &port, &ctx); + 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"); diff --git a/crates/nodebox-gui/src/render_worker.rs b/crates/nodebox-gui/src/render_worker.rs index 83081916..cbe304ed 100644 --- a/crates/nodebox-gui/src/render_worker.rs +++ b/crates/nodebox-gui/src/render_worker.rs @@ -71,6 +71,7 @@ pub enum RenderResult { Success { id: RenderRequestId, geometry: Vec, + output: crate::eval::NodeOutput, errors: Vec, }, /// Evaluation was cancelled before completion. @@ -248,10 +249,11 @@ fn render_worker_loop( ); match result { - crate::eval::EvalOutcome::Completed { geometry, errors } => { + crate::eval::EvalOutcome::Completed { geometry, output, errors } => { let _ = result_tx.send(RenderResult::Success { id: final_id, geometry, + output, errors, }); } diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index bbef605b..9b001ae9 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use nodebox_core::geometry::{Path as GeoPath, Color, Point}; use nodebox_core::node::{Node, NodeLibrary, MenuItem, Port, PortRange, Widget}; +use crate::eval::NodeOutput; /// The main application state. pub struct AppState { @@ -30,6 +31,9 @@ pub struct AppState { /// 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, } impl Default for AppState { @@ -55,6 +59,7 @@ impl AppState { background_color: Color::WHITE, library, node_errors: HashMap::new(), + node_output: NodeOutput::None, } } @@ -84,6 +89,7 @@ impl AppState { self.current_file = None; self.dirty = false; self.geometry.clear(); + self.node_output = NodeOutput::None; self.selected_node = None; self.node_errors.clear(); } @@ -105,6 +111,7 @@ impl AppState { self.dirty = false; self.selected_node = None; self.geometry.clear(); // Render worker will populate + self.node_output = NodeOutput::None; self.node_errors.clear(); Ok(()) diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index a60b838c..5c576d55 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -234,6 +234,10 @@ pub struct ViewerPane { 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, } impl Default for ViewerPane { @@ -264,6 +268,8 @@ impl ViewerPane { #[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, } } @@ -353,6 +359,24 @@ impl ViewerPane { /// 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 + let is_geometry = state.library.is_rendered_output_geometry(); + let was_available = self.visual_tab_available; + + 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); @@ -361,87 +385,122 @@ impl ViewerPane { // 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 - 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; + 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.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, + "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.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, + "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.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, 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.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; + 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; + } } - x = new_x; // Zoom controls on the right side: [-] [100%] [+] let zoom_controls_width = 24.0 + 48.0 + 24.0 + theme::PADDING; // minus + percent + plus + right padding @@ -745,71 +804,152 @@ impl ViewerPane { /// Show the data view with spreadsheet table. fn show_data_view(&mut self, ui: &mut egui::Ui, state: &AppState) { - // Sub-header bar with view mode toggle and stats - self.show_data_view_header(ui, state); + if state.node_output.is_geometry() { + // Geometry output: show Paths or Points table + if state.geometry.is_empty() { + Self::show_data_empty(ui); + return; + } - // Table body - 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 { + // 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(); - match self.data_view_mode { - DataViewMode::Paths => Self::show_paths_table(ui, state), - DataViewMode::Points => Self::show_points_table(ui, state), + 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 - /// Show the data view sub-header with mode toggle and stats. - fn show_data_view_header(&mut self, ui: &mut egui::Ui, state: &AppState) { - let header_height = 28.0; - let (rect, _) = ui.allocate_exact_size( - egui::vec2(ui.available_width(), header_height), - egui::Sense::hover(), - ); + // 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 + }; - // Background - ui.painter().rect_filled(rect, 0.0, theme::SLATE_800); + 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 + }; - // Bottom border - 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::SLATE_950), - ); + // 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), + ); + }); + }); - // Segmented control for Paths/Points - let selected_index = if self.data_view_mode == DataViewMode::Paths { 0 } else { 1 }; - let (clicked_index, _x) = components::header_segmented_control( - ui, - rect, - rect.left() + theme::PADDING, - ["Paths", "Points"], - selected_index, - ); - if let Some(index) = clicked_index { - self.data_view_mode = if index == 0 { DataViewMode::Paths } else { DataViewMode::Points }; - } + // 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::SLATE_600), + egui::StrokeKind::Inside, + ); + } + }); + } - // Stats on the right side - let total_paths = state.geometry.len(); - let total_points: usize = state.geometry.iter() - .flat_map(|p| &p.contours) - .map(|c| c.points.len()) - .sum(); - let stats_text = format!("{} paths, {} points", total_paths, total_points); - ui.painter().text( - egui::pos2(rect.right() - theme::PADDING, rect.center().y), - egui::Align2::RIGHT_CENTER, - &stats_text, - egui::FontId::proportional(10.0), - theme::TEXT_DISABLED, - ); + // 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 empty state when no geometry data is available. + /// 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); @@ -820,7 +960,7 @@ impl ViewerPane { ); ui.add_space(8.0); ui.label( - egui::RichText::new("Render a node to see its geometry data here.") + egui::RichText::new("Render a node to see its data here.") .color(theme::TEXT_DISABLED) .size(11.0), ); @@ -850,6 +990,7 @@ impl ViewerPane { 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) @@ -970,6 +1111,7 @@ impl ViewerPane { 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) diff --git a/crates/nodebox-gui/tests/cancellation_tests.rs b/crates/nodebox-gui/tests/cancellation_tests.rs index 7ed68e9e..80e7dd3b 100644 --- a/crates/nodebox-gui/tests/cancellation_tests.rs +++ b/crates/nodebox-gui/tests/cancellation_tests.rs @@ -75,7 +75,7 @@ fn test_evaluation_completes_without_cancellation() { let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); match outcome { - EvalOutcome::Completed { geometry, errors } => { + EvalOutcome::Completed { geometry, errors, .. } => { assert!(errors.is_empty(), "Should have no errors"); assert_eq!(geometry.len(), 25, "Should have 25 rectangles (5x5 grid)"); } @@ -147,7 +147,7 @@ fn test_cache_reused_after_cancellation() { let outcome1 = evaluate_network_cancellable(&library, &token1, &mut cache, &port, &ctx); match outcome1 { - EvalOutcome::Completed { geometry, errors } => { + EvalOutcome::Completed { geometry, errors, .. } => { assert!(errors.is_empty()); assert_eq!(geometry.len(), 100); } @@ -163,7 +163,7 @@ fn test_cache_reused_after_cancellation() { let outcome2 = evaluate_network_cancellable(&library, &token2, &mut cache, &port, &ctx); match outcome2 { - EvalOutcome::Completed { geometry, errors } => { + EvalOutcome::Completed { geometry, errors, .. } => { assert!(errors.is_empty()); assert_eq!(geometry.len(), 100); } @@ -245,7 +245,7 @@ fn test_empty_network_not_affected_by_cancellation() { // Empty network should complete (nothing to cancel) match outcome { - EvalOutcome::Completed { geometry, errors } => { + EvalOutcome::Completed { geometry, errors, .. } => { assert!(geometry.is_empty()); assert!(errors.is_empty()); } diff --git a/crates/nodebox-gui/tests/file_tests.rs b/crates/nodebox-gui/tests/file_tests.rs index 444ff124..96a15c2a 100644 --- a/crates/nodebox-gui/tests/file_tests.rs +++ b/crates/nodebox-gui/tests/file_tests.rs @@ -171,19 +171,19 @@ fn test_evaluate_primitives() { test_library.root.rendered_child = Some("rect1".to_string()); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&test_library, &port, &ctx); + 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_port_and_context(); - let (paths, _errors) = evaluate_network(&test_library, &port, &ctx); + 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_port_and_context(); - let (paths, _errors) = evaluate_network(&test_library, &port, &ctx); + let (paths, _output, _errors) = evaluate_network(&test_library, &port, &ctx); assert_eq!(paths.len(), 1, "polygon1 should produce one path"); } @@ -193,7 +193,7 @@ fn test_evaluate_primitives_full() { // The rendered child is "combine1" which uses list.combine let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&library, &port, &ctx); + 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"); @@ -213,7 +213,7 @@ fn test_evaluate_colorized_primitives() { // Test colorized rect (colorize1 <- rect1) test_library.root.rendered_child = Some("colorize1".to_string()); let (port, ctx) = test_port_and_context(); - let (paths, _errors) = evaluate_network(&test_library, &port, &ctx); + 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"); @@ -232,21 +232,21 @@ fn test_primitives_shapes_at_different_positions() { let mut test_library = library.clone(); test_library.root.rendered_child = Some("rect1".to_string()); let (port, ctx) = test_port_and_context(); - let (rect_paths, _errors) = evaluate_network(&test_library, &port, &ctx); + 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, _errors) = evaluate_network(&test_library, &port, &ctx); + 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, _errors) = evaluate_network(&test_library, &port, &ctx); + 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; From 49fe67d84105604d6ef423feac51107d05622e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 18:45:40 +0000 Subject: [PATCH 024/100] Clear rendered_child when deleting the rendered node When deleting a node, check if it is the currently rendered node and clear rendered_child so the canvas shows an empty state instead of continuing to reference a deleted node. https://claude.ai/code/session_01Je3DEjek51X2y6E7rtzx9V --- crates/nodebox-gui/src/network_view.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index bc1e3340..efbde72a 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -450,6 +450,10 @@ impl NetworkView { library.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); + // If the deleted node was the rendered node, clear the rendered child + if library.root.rendered_child.as_deref() == Some(name.as_str()) { + library.root.rendered_child = None; + } } self.selected.clear(); } From 0e07ea855d8ae51042076fd3bcd6d5c58e3e4d81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 18:59:34 +0000 Subject: [PATCH 025/100] Add NodeLibrary clone benchmark and optimization plan Benchmark measures deep clone vs Arc::clone cost for NodeLibrary at various document sizes (5-200 nodes). Results show 200x-6800x speedup potential with Arc + copy-on-write approach. https://claude.ai/code/session_01CSUNqp8g5ebX9BeyKVK4Yj --- PLAN.md | 92 +++++++++++ crates/nodebox-core/Cargo.toml | 4 + crates/nodebox-core/benches/clone_library.rs | 162 +++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 PLAN.md create mode 100644 crates/nodebox-core/benches/clone_library.rs diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..edc2fe6b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,92 @@ +# Plan: Optimize NodeLibrary Cloning with Arc + Copy-on-Write + +## Problem + +Every time a render is dispatched (`app.rs:419`), the entire `NodeLibrary` is deep-cloned +via `self.state.library.clone()`. This recursively copies the full node tree (all `Node` +children, connections, ports, values, strings, etc.). The cloned library is sent to the +render worker thread, where it is **only read, never mutated**. Additionally, the undo/redo +system (`history.rs`) deep-clones the library on every `save_state` call. + +## Solution + +Wrap `NodeLibrary` in `Arc` and use copy-on-write (COW) semantics via +`Arc::make_mut()`. This makes: + +- **Render dispatch**: O(1) — just increment a reference count instead of deep-cloning +- **Undo/redo snapshots**: O(1) — `Arc::clone()` instead of deep clone +- **Mutations**: Only clone when a render is actively holding a reference (COW); otherwise + mutate in-place with no overhead + +### How `Arc::make_mut` works + +- If `Arc` refcount == 1 (no one else holds a reference): returns `&mut T` directly — zero cost +- If `Arc` refcount > 1 (render worker still using it): clones the inner `T` first, then + returns `&mut T` to the new unique copy + +This means mutations are free in the common case (render already finished), and only pay +the clone cost when a render is actually in progress — which would have been necessary anyway. + +## Changes + +### Step 1: `state.rs` — Change library field type + +- Change `pub library: NodeLibrary` → `pub library: Arc` +- Add `use std::sync::Arc;` + +### Step 2: `history.rs` — Arc-ify the undo/redo stacks + +- Change `undo_stack: Vec` → `Vec>` +- Change `redo_stack: Vec` → `Vec>` +- Change `last_saved_state: Option` → `Option>` +- `save_state(&mut self, library: &Arc)` — use `Arc::clone(library)` instead + of `library.clone()` (which would deep-clone due to Deref) +- `undo()` and `redo()` return `Option>` instead of `Option` + +### Step 3: `render_worker.rs` — Accept Arc in render requests + +- Change `RenderRequest::Evaluate { library: NodeLibrary }` → `library: Arc` +- Update `request_render()` signature: `library: Arc` +- Update `render_worker_loop()` and `drain_to_latest()` to use `Arc` +- At evaluation call site: pass `&final_library` (Arc auto-derefs to `&NodeLibrary`) + +### Step 4: `app.rs` — Use Arc::clone for render dispatch, Arc::make_mut for mutations + +- Render dispatch (`line ~420`): `Arc::clone(&self.state.library)` instead of + `self.state.library.clone()` +- New file / load file: `self.state.library = Arc::new(NodeLibrary::new(...))` +- Undo/redo handling: assign `Arc` directly +- Node creation (`line ~784`): `Arc::make_mut(&mut self.state.library).root.children.push(...)` +- `handle_four_point_change`: `Arc::make_mut(&mut self.state.library).root.child_mut(...)` +- `handle_parameter_change`: `Arc::make_mut(&mut self.state.library).root.child_mut(...)` +- History `save_state` calls: pass `&self.state.library` (already Arc) + +### Step 5: `network_view.rs` — Accept &mut Arc + +- Change `show()` signature from `library: &mut NodeLibrary` → `library: &mut Arc` +- Read-only access (iterating children, reading connections): works via `Deref` — no changes +- Mutation sites (node dragging, connection add/remove, rendered_child, delete): + use `Arc::make_mut(library)` before mutating + +### Step 6: `panels.rs` — Mutate through Arc::make_mut + +- Where `state.library.root.child_mut(...)` is used: replace with + `Arc::make_mut(&mut state.library).root.child_mut(...)` +- Where `state.library.set_width/set_height` is used: replace with + `Arc::make_mut(&mut state.library).set_width(...)` +- Read-only access (iterating children for display): works via Deref — no changes + +### Step 7: `node_library.rs` — Accept &mut Arc for node creation + +- Where `library.root.children.push(node)` is used: replace with + `Arc::make_mut(library).root.children.push(node)` + +### Step 8: Fix tests + +- Update test code in `app.rs` that directly mutates `app.state.library` to use + `Arc::make_mut` or `Arc::new()` + +### Step 9: Build and verify + +- Run `cargo build` and fix any remaining compilation errors +- Run `cargo test` and verify all tests pass diff --git a/crates/nodebox-core/Cargo.toml b/crates/nodebox-core/Cargo.toml index 26b30d75..a721149b 100644 --- a/crates/nodebox-core/Cargo.toml +++ b/crates/nodebox-core/Cargo.toml @@ -12,6 +12,10 @@ uuid = { workspace = true } font-kit = { workspace = true } pathfinder_geometry = { workspace = true } +[[bench]] +name = "clone_library" +harness = false + [dev-dependencies] proptest = { workspace = true } approx = { workspace = true } 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"); +} From cd5531e202bf476729e33b2ed65cf941ca023be6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 19:18:25 +0000 Subject: [PATCH 026/100] Wrap NodeLibrary in Arc for copy-on-write render dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace deep clone of NodeLibrary on every render dispatch with Arc::clone (O(1) ref count bump). Mutations use Arc::make_mut for copy-on-write semantics — only cloning when a render is actively holding a reference. Key changes: - AppState.library: NodeLibrary → Arc - History stacks: Vec → Vec> - RenderRequest/worker: accepts Arc - All mutation sites use Arc::make_mut for COW - Benchmark shows 200x-6800x speedup on render dispatch path https://claude.ai/code/session_01CSUNqp8g5ebX9BeyKVK4Yj --- crates/nodebox-gui/src/app.rs | 30 +++++++++++------------ crates/nodebox-gui/src/history.rs | 25 ++++++++++--------- crates/nodebox-gui/src/network_view.rs | 20 +++++++++------ crates/nodebox-gui/src/node_library.rs | 5 ++-- crates/nodebox-gui/src/panels.rs | 9 ++++--- crates/nodebox-gui/src/render_worker.rs | 8 +++--- crates/nodebox-gui/src/state.rs | 9 ++++--- crates/nodebox-gui/tests/history_tests.rs | 5 ++-- 8 files changed, 61 insertions(+), 50 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index e1550aa0..a9440b84 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -236,7 +236,7 @@ impl NodeBoxApp { #[allow(dead_code)] pub fn new_for_testing_empty() -> Self { let mut state = AppState::new(); - state.library = nodebox_core::node::NodeLibrary::new("test"); + state.library = Arc::new(nodebox_core::node::NodeLibrary::new("test")); state.geometry.clear(); let hash = Self::hash_library(&state.library); Self { @@ -418,7 +418,7 @@ impl NodeBoxApp { self.project_context.frame = self.animation_bar.frame(); self.render_worker.request_render( id, - self.state.library.clone(), + Arc::clone(&self.state.library), cancel_token, self.port.clone(), self.project_context.clone(), @@ -781,7 +781,7 @@ impl eframe::App for NodeBoxApp { 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); + Arc::make_mut(&mut self.state.library).root.children.push(new_node); // Select the new node self.state.selected_node = Some(node_name); } @@ -852,7 +852,7 @@ 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) { + 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)); @@ -870,7 +870,7 @@ impl NodeBoxApp { /// 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) { + 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) @@ -1063,14 +1063,14 @@ mod tests { let mut app = NodeBoxApp::new_for_testing(); // Set up a node with a position parameter - app.state.library.root.children.push( + 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)), ); - app.state.library.root.rendered_child = Some("ellipse1".to_string()); + 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 @@ -1109,7 +1109,7 @@ mod tests { let mut app = NodeBoxApp::new_for_testing(); // Set up a node with position and size parameters - app.state.library.root.children.push( + Arc::make_mut(&mut app.state.library).root.children.push( Node::new("rect1") .with_prototype("corevector.rect") .with_input(Port::float("x", 0.0)) @@ -1117,7 +1117,7 @@ mod tests { .with_input(Port::float("width", 100.0)) .with_input(Port::float("height", 100.0)), ); - app.state.library.root.rendered_child = Some("rect1".to_string()); + 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 @@ -1158,21 +1158,21 @@ mod tests { let mut app = NodeBoxApp::new_for_testing(); // Set up a node with a width parameter - app.state.library.root.children.push( + 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.state.library.root.rendered_child = Some("rect1".to_string()); + 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.render_pending = false; // Modify the width parameter (simulates what happens when user changes value in panel) - if let Some(node) = app.state.library.root.child_mut("rect1") { + 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); } @@ -1201,21 +1201,21 @@ mod tests { let mut app = NodeBoxApp::new_for_testing(); // Set up a rect node - app.state.library.root.children.push( + 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.state.library.root.rendered_child = Some("rect1".to_string()); + 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) = app.state.library.root.child_mut("rect1") { + 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); } diff --git a/crates/nodebox-gui/src/history.rs b/crates/nodebox-gui/src/history.rs index 7bca3e57..d195488f 100644 --- a/crates/nodebox-gui/src/history.rs +++ b/crates/nodebox-gui/src/history.rs @@ -1,5 +1,6 @@ //! Undo/redo history management. +use std::sync::Arc; use nodebox_core::node::NodeLibrary; /// Maximum number of undo states to keep. @@ -8,12 +9,12 @@ const MAX_HISTORY: usize = 50; /// The undo/redo history manager. pub struct History { /// Past states (undo stack). - undo_stack: Vec, + undo_stack: Vec>, /// Future states (redo stack). - redo_stack: Vec, + redo_stack: Vec>, /// The last saved state (to track changes). #[allow(dead_code)] - last_saved_state: Option, + last_saved_state: Option>, } impl Default for History { @@ -44,8 +45,8 @@ impl History { /// 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()); + pub fn save_state(&mut self, library: &Arc) { + self.undo_stack.push(Arc::clone(library)); // Clear redo stack when new changes are made self.redo_stack.clear(); @@ -58,10 +59,10 @@ impl History { /// 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 { + pub fn undo(&mut self, current: &Arc) -> Option> { if let Some(previous) = self.undo_stack.pop() { // Save current state for redo - self.redo_stack.push(current.clone()); + self.redo_stack.push(Arc::clone(current)); Some(previous) } else { None @@ -69,10 +70,10 @@ impl History { } /// Redo the last undone change, returning the restored state. - pub fn redo(&mut self, current: &NodeLibrary) -> Option { + pub fn redo(&mut self, current: &Arc) -> Option> { if let Some(next) = self.redo_stack.pop() { // Save current state for undo - self.undo_stack.push(current.clone()); + self.undo_stack.push(Arc::clone(current)); Some(next) } else { None @@ -81,15 +82,15 @@ impl History { /// Mark the current state as saved. #[allow(dead_code)] - pub fn mark_saved(&mut self, library: &NodeLibrary) { - self.last_saved_state = Some(library.clone()); + 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 != library, + Some(saved) => saved.as_ref() != library, None => true, // Never saved, so always has changes } } diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index bc1e3340..d4f29433 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -4,6 +4,7 @@ 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::{HashMap, HashSet}; +use std::sync::Arc; use crate::icon_cache::IconCache; use crate::pan_zoom::PanZoom; @@ -101,7 +102,7 @@ impl NetworkView { /// Show the network view. Returns any action that should be handled by the app. /// /// The `node_errors` map contains per-node error messages for visual feedback. - pub fn show(&mut self, ui: &mut egui::Ui, library: &mut NodeLibrary, node_errors: &HashMap) -> NetworkAction { + pub fn show(&mut self, ui: &mut egui::Ui, library: &mut Arc, node_errors: &HashMap) -> NetworkAction { let mut action = NetworkAction::None; let (response, painter) = @@ -361,7 +362,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); @@ -411,8 +412,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; } @@ -422,8 +424,9 @@ 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(); } @@ -433,23 +436,24 @@ impl NetworkView { // 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 if let Some((from, to, port)) = connection_to_create { - library.root.connections.push(Connection::new(from, to, port)); + Arc::make_mut(library).root.connections.push(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); } self.selected.clear(); } diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index d072e55d..a7667cc7 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -4,6 +4,7 @@ #![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}; @@ -298,7 +299,7 @@ impl NodeLibraryBrowser { } /// 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 { + pub fn show(&mut self, ui: &mut egui::Ui, library: &mut Arc) -> Option { let mut created_node = None; // Search box @@ -360,7 +361,7 @@ impl NodeLibraryBrowser { // Create the node let node = create_node_from_template(template, library, pos); let node_name = node.name.clone(); - library.root.children.push(node); + Arc::make_mut(library).root.children.push(node); created_node = Some(node_name); } ui.label(template.name); diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index d203f609..ba93068f 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -1,7 +1,8 @@ //! UI panels for the NodeBox application. +use std::sync::Arc; use eframe::egui::{self, Sense, TextStyle}; -use nodebox_core::node::{PortType, Widget}; +use nodebox_core::node::{NodeLibrary, PortType, Widget}; use nodebox_core::Value; use nodebox_port::{FileFilter, Port, PortError, ProjectContext}; use crate::components; @@ -71,7 +72,7 @@ impl ParameterPanel { ); // Find the node in the library for mutation - if let Some(node) = state.library.root.child_mut(&node_name) { + 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(); @@ -776,7 +777,7 @@ impl ParameterPanel { // Update the property if changed if (state.library.width() - width).abs() > 0.001 { - state.library.set_width(width); + Arc::make_mut(&mut state.library).set_width(width); } }); @@ -814,7 +815,7 @@ impl ParameterPanel { // Update the property if changed if (state.library.height() - height).abs() > 0.001 { - state.library.set_height(height); + Arc::make_mut(&mut state.library).set_height(height); } }); } diff --git a/crates/nodebox-gui/src/render_worker.rs b/crates/nodebox-gui/src/render_worker.rs index cbe304ed..2b8c7112 100644 --- a/crates/nodebox-gui/src/render_worker.rs +++ b/crates/nodebox-gui/src/render_worker.rs @@ -55,7 +55,7 @@ pub enum RenderRequest { /// Evaluate the network and return geometry. Evaluate { id: RenderRequestId, - library: NodeLibrary, + library: Arc, cancel_token: CancellationToken, port: Arc, project_context: ProjectContext, @@ -179,7 +179,7 @@ impl RenderWorkerHandle { pub fn request_render( &self, id: RenderRequestId, - library: NodeLibrary, + library: Arc, cancel_token: CancellationToken, port: Arc, project_context: ProjectContext, @@ -272,12 +272,12 @@ fn render_worker_loop( /// Drain any pending requests and return the most recent one. fn drain_to_latest( mut id: RenderRequestId, - mut library: NodeLibrary, + mut library: Arc, mut cancel_token: CancellationToken, mut port: Arc, mut project_context: ProjectContext, rx: &mpsc::Receiver, -) -> (RenderRequestId, NodeLibrary, CancellationToken, Arc, ProjectContext) { +) -> (RenderRequestId, Arc, CancellationToken, Arc, ProjectContext) { while let Ok(req) = rx.try_recv() { match req { RenderRequest::Evaluate { diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index 9b001ae9..b35c8ac3 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -2,6 +2,7 @@ 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; @@ -27,7 +28,9 @@ pub struct AppState { pub background_color: Color, /// The node library (document). - pub library: NodeLibrary, + /// 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, @@ -48,7 +51,7 @@ impl AppState { /// Note: Geometry starts empty - the render worker will evaluate with /// the proper Port and populate it. pub fn new() -> Self { - let library = Self::create_demo_library(); + let library = Arc::new(Self::create_demo_library()); Self { current_file: None, @@ -106,7 +109,7 @@ impl AppState { populate_default_ports(&mut library.root); // Update state - self.library = library; + self.library = Arc::new(library); self.current_file = Some(path.to_path_buf()); self.dirty = false; self.selected_node = None; diff --git a/crates/nodebox-gui/tests/history_tests.rs b/crates/nodebox-gui/tests/history_tests.rs index b7eefe6a..d6162126 100644 --- a/crates/nodebox-gui/tests/history_tests.rs +++ b/crates/nodebox-gui/tests/history_tests.rs @@ -2,10 +2,11 @@ mod common; +use std::sync::Arc; use nodebox_gui::{History, Node, NodeLibrary, Port}; /// Create a simple test library with an ellipse. -fn create_test_library(x: f64) -> NodeLibrary { +fn create_test_library(x: f64) -> Arc { let mut library = NodeLibrary::new("test"); library.root = Node::network("root") .with_child( @@ -17,7 +18,7 @@ fn create_test_library(x: f64) -> NodeLibrary { .with_input(Port::float("height", 100.0)), ) .with_rendered_child("ellipse1"); - library + Arc::new(library) } #[test] From 4b76ebe7bc38315e06515515237b1378f58ab409 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 19:19:11 +0000 Subject: [PATCH 027/100] Remove temporary plan file https://claude.ai/code/session_01CSUNqp8g5ebX9BeyKVK4Yj --- PLAN.md | 92 --------------------------------------------------------- 1 file changed, 92 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index edc2fe6b..00000000 --- a/PLAN.md +++ /dev/null @@ -1,92 +0,0 @@ -# Plan: Optimize NodeLibrary Cloning with Arc + Copy-on-Write - -## Problem - -Every time a render is dispatched (`app.rs:419`), the entire `NodeLibrary` is deep-cloned -via `self.state.library.clone()`. This recursively copies the full node tree (all `Node` -children, connections, ports, values, strings, etc.). The cloned library is sent to the -render worker thread, where it is **only read, never mutated**. Additionally, the undo/redo -system (`history.rs`) deep-clones the library on every `save_state` call. - -## Solution - -Wrap `NodeLibrary` in `Arc` and use copy-on-write (COW) semantics via -`Arc::make_mut()`. This makes: - -- **Render dispatch**: O(1) — just increment a reference count instead of deep-cloning -- **Undo/redo snapshots**: O(1) — `Arc::clone()` instead of deep clone -- **Mutations**: Only clone when a render is actively holding a reference (COW); otherwise - mutate in-place with no overhead - -### How `Arc::make_mut` works - -- If `Arc` refcount == 1 (no one else holds a reference): returns `&mut T` directly — zero cost -- If `Arc` refcount > 1 (render worker still using it): clones the inner `T` first, then - returns `&mut T` to the new unique copy - -This means mutations are free in the common case (render already finished), and only pay -the clone cost when a render is actually in progress — which would have been necessary anyway. - -## Changes - -### Step 1: `state.rs` — Change library field type - -- Change `pub library: NodeLibrary` → `pub library: Arc` -- Add `use std::sync::Arc;` - -### Step 2: `history.rs` — Arc-ify the undo/redo stacks - -- Change `undo_stack: Vec` → `Vec>` -- Change `redo_stack: Vec` → `Vec>` -- Change `last_saved_state: Option` → `Option>` -- `save_state(&mut self, library: &Arc)` — use `Arc::clone(library)` instead - of `library.clone()` (which would deep-clone due to Deref) -- `undo()` and `redo()` return `Option>` instead of `Option` - -### Step 3: `render_worker.rs` — Accept Arc in render requests - -- Change `RenderRequest::Evaluate { library: NodeLibrary }` → `library: Arc` -- Update `request_render()` signature: `library: Arc` -- Update `render_worker_loop()` and `drain_to_latest()` to use `Arc` -- At evaluation call site: pass `&final_library` (Arc auto-derefs to `&NodeLibrary`) - -### Step 4: `app.rs` — Use Arc::clone for render dispatch, Arc::make_mut for mutations - -- Render dispatch (`line ~420`): `Arc::clone(&self.state.library)` instead of - `self.state.library.clone()` -- New file / load file: `self.state.library = Arc::new(NodeLibrary::new(...))` -- Undo/redo handling: assign `Arc` directly -- Node creation (`line ~784`): `Arc::make_mut(&mut self.state.library).root.children.push(...)` -- `handle_four_point_change`: `Arc::make_mut(&mut self.state.library).root.child_mut(...)` -- `handle_parameter_change`: `Arc::make_mut(&mut self.state.library).root.child_mut(...)` -- History `save_state` calls: pass `&self.state.library` (already Arc) - -### Step 5: `network_view.rs` — Accept &mut Arc - -- Change `show()` signature from `library: &mut NodeLibrary` → `library: &mut Arc` -- Read-only access (iterating children, reading connections): works via `Deref` — no changes -- Mutation sites (node dragging, connection add/remove, rendered_child, delete): - use `Arc::make_mut(library)` before mutating - -### Step 6: `panels.rs` — Mutate through Arc::make_mut - -- Where `state.library.root.child_mut(...)` is used: replace with - `Arc::make_mut(&mut state.library).root.child_mut(...)` -- Where `state.library.set_width/set_height` is used: replace with - `Arc::make_mut(&mut state.library).set_width(...)` -- Read-only access (iterating children for display): works via Deref — no changes - -### Step 7: `node_library.rs` — Accept &mut Arc for node creation - -- Where `library.root.children.push(node)` is used: replace with - `Arc::make_mut(library).root.children.push(node)` - -### Step 8: Fix tests - -- Update test code in `app.rs` that directly mutates `app.state.library` to use - `Arc::make_mut` or `Arc::new()` - -### Step 9: Build and verify - -- Run `cargo build` and fix any remaining compilation errors -- Run `cargo test` and verify all tests pass From 518df69bb364a92ad6759fdac4ff79d6cac0bf4d Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 20:19:18 +0100 Subject: [PATCH 028/100] Fix list evaluation for Color outputs (sample -> rgb_color returns None) Add Colors(Vec) variant to NodeOutput so collect_results() can properly aggregate multiple Color results from list iteration, instead of falling through to the path default and returning None. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/eval.rs | 62 ++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index faedc90c..0c872b8c 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -70,6 +70,8 @@ pub enum NodeOutput { Strings(Vec), /// A color value. Color(Color), + /// A list of color values. + Colors(Vec), /// A boolean value. Boolean(bool), /// A list of boolean values. @@ -148,6 +150,7 @@ impl NodeOutput { 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()], @@ -163,7 +166,7 @@ impl NodeOutput { NodeOutput::Int(_) | NodeOutput::Ints(_) => "int", NodeOutput::String(_) | NodeOutput::Strings(_) => "string", NodeOutput::Boolean(_) | NodeOutput::Booleans(_) => "boolean", - NodeOutput::Color(_) => "color", + NodeOutput::Color(_) | NodeOutput::Colors(_) => "color", NodeOutput::Point(_) | NodeOutput::Points(_) => "point", NodeOutput::Path(_) | NodeOutput::Paths(_) => "path", } @@ -179,19 +182,21 @@ impl NodeOutput { NodeOutput::Ints(is) => is.len(), NodeOutput::Strings(ss) => ss.len(), NodeOutput::Booleans(bs) => bs.len(), + NodeOutput::Colors(cs) => cs.len(), _ => 1, } } /// Returns true if this output is a color type. pub fn is_color(&self) -> bool { - matches!(self, NodeOutput::Color(_)) + 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 { + pub fn color_at(&self, index: usize) -> Option { match self { NodeOutput::Color(c) => Some(*c), + NodeOutput::Colors(cs) => cs.get(index).copied(), _ => None, } } @@ -208,6 +213,7 @@ impl NodeOutput { 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(), v => vec![v.clone()], // Single values remain single } } @@ -221,6 +227,7 @@ impl NodeOutput { NodeOutput::Ints(is) => is.len(), NodeOutput::Strings(ss) => ss.len(), NodeOutput::Booleans(bs) => bs.len(), + NodeOutput::Colors(cs) => cs.len(), NodeOutput::None => 0, _ => 1, } @@ -511,6 +518,13 @@ fn collect_results(results: Vec) -> NodeOutput { }).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) + } _ => { // Default: collect as Paths (geometry operations) let paths: Vec = results.into_iter() @@ -3201,6 +3215,48 @@ mod tests { ); } + #[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(NodePort::int("amount", 5)) + .with_input(NodePort::float("start", 0.0)) + .with_input(NodePort::float("end", 255.0)), + ) + .with_child( + Node::new("rgb1") + .with_prototype("color.rgb_color") + .with_input(NodePort::float("red", 0.0)) + .with_input(NodePort::float("green", 0.0)) + .with_input(NodePort::float("blue", 0.0)) + .with_input(NodePort::float("alpha", 255.0)) + .with_input(NodePort::float("range", 255.0)), + ) + .with_connection(Connection::new("sample1", "rgb1", "red")) + .with_rendered_child("rgb1"); + + let (port, ctx) = test_port_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 From a817da98ef887a8952f13d8935e50f4e469a107c Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 20:40:58 +0100 Subject: [PATCH 029/100] Fix compile warning --- crates/nodebox-gui/src/eval.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 0c872b8c..9e6796d6 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -885,15 +885,6 @@ fn get_floats(inputs: &HashMap, name: &str) -> Vec { } } -/// Get a list of string values from input. -fn get_strings(inputs: &HashMap, name: &str) -> Vec { - match inputs.get(name) { - Some(NodeOutput::Strings(ss)) => ss.clone(), - Some(NodeOutput::String(s)) => vec![s.clone()], - _ => Vec::new(), - } -} - /// Get a list of boolean values from input. fn get_booleans(inputs: &HashMap, name: &str) -> Vec { match inputs.get(name) { From 146deb2c4e46c6bd32d225a21c08db2be344769f Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 21:01:36 +0100 Subject: [PATCH 030/100] Fix duplicate connections when reconnecting to an occupied input port Add Node::connect() method that removes any existing connection to the same input port before adding the new one. Update network_view to use it instead of directly pushing connections. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-core/src/node/node.rs | 89 ++++++++++++++++++++++++++ crates/nodebox-gui/src/network_view.rs | 4 +- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-core/src/node/node.rs b/crates/nodebox-core/src/node/node.rs index 9a0bcd43..40edf1e3 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()); @@ -283,6 +298,80 @@ 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") diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index bc1e3340..7b8eb382 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -436,9 +436,9 @@ impl NetworkView { 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)); + library.root.connect(Connection::new(from, to, port)); } // Handle delete key for selected nodes (but not when editing text) From 220f6583b0b00e44bb68144862400659e494b64b Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 21:31:05 +0100 Subject: [PATCH 031/100] Add CLAUDE.md file that references AGENTS.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CLAUDE.md 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 From 74b3047c0b9f631dc8f344b7550afd331c6e9d96 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 21:44:19 +0100 Subject: [PATCH 032/100] Add textpath node with font selector combo box Implement the textpath node that converts text to vector paths, and add a Font widget rendered as a combo box populated with system fonts via a new list_fonts() method on the Port trait. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/nodebox-gui/src/eval.rs | 10 ++++++++++ crates/nodebox-gui/src/node_library.rs | 20 ++++++++++++++++++++ crates/nodebox-gui/src/panels.rs | 18 ++++++++++++++++++ crates/nodebox-port/Cargo.toml | 4 ++++ crates/nodebox-port/src/desktop.rs | 7 +++++++ crates/nodebox-port/src/lib.rs | 7 +++++++ 7 files changed, 67 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b2d6b61e..c0876c33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3016,6 +3016,7 @@ version = "0.1.0" dependencies = [ "arboard", "directories", + "font-kit", "log", "rfd", "tempfile", diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index faedc90c..9c6465ac 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -3,6 +3,7 @@ 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; @@ -981,6 +982,15 @@ fn execute_node( let path = nodebox_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" => { diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index d072e55d..80b82e46 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -67,6 +67,12 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "geometry", description: "Create a grid of points", }, + NodeTemplate { + name: "textpath", + prototype: "corevector.textpath", + category: "geometry", + description: "Convert text to a vector path", + }, // Transform nodes NodeTemplate { name: "translate", @@ -518,6 +524,20 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::point("offset", Point::new(10.0, 10.0))) .with_input(Port::int("seed", 0)); } + "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)); + } "import_svg" => { node = node .with_input(Port::string("file", "").with_widget(Widget::File)) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index d203f609..02f3e28c 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -440,6 +440,24 @@ impl ParameterPanel { } } } + 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 { diff --git a/crates/nodebox-port/Cargo.toml b/crates/nodebox-port/Cargo.toml index 26c5dfca..bfc9e5d4 100644 --- a/crates/nodebox-port/Cargo.toml +++ b/crates/nodebox-port/Cargo.toml @@ -34,5 +34,9 @@ version = "2" [target.'cfg(not(target_arch = "wasm32"))'.dependencies.directories] version = "5" +# Font enumeration (desktop only) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.font-kit] +workspace = true + [dev-dependencies] tempfile = "3" diff --git a/crates/nodebox-port/src/desktop.rs b/crates/nodebox-port/src/desktop.rs index 939086b3..ab4e344e 100644 --- a/crates/nodebox-port/src/desktop.rs +++ b/crates/nodebox-port/src/desktop.rs @@ -496,6 +496,13 @@ impl Port for DesktopPort { )) } } + + 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)] diff --git a/crates/nodebox-port/src/lib.rs b/crates/nodebox-port/src/lib.rs index 53dd1a43..0cd1b9f9 100644 --- a/crates/nodebox-port/src/lib.rs +++ b/crates/nodebox-port/src/lib.rs @@ -771,6 +771,9 @@ pub trait Port: Send + Sync { /// /// * `Err(PortError::Unsupported)` - No config directory on this platform fn get_config_dir(&self) -> Result; + + /// List available font families on the system. + fn list_fonts(&self) -> Vec; } /// A minimal Port implementation for testing. @@ -921,6 +924,10 @@ impl Port for TestPort { fn get_config_dir(&self) -> Result { Err(PortError::Unsupported) } + + fn list_fonts(&self) -> Vec { + Vec::new() + } } #[cfg(test)] From 8e3b86949434099b9e0e8d5e34de85ec45657f93 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 21:49:53 +0100 Subject: [PATCH 033/100] Add Colors variant to NodeOutput for list-matched color nodes When a list-producing node (e.g. sample) connects to a color node's input, list-matching runs the color node once per item but collect_results() had no Color case, falling through to the default path and producing NodeOutput::None. This adds the missing Colors(Vec) variant and updates all NodeOutput methods and collect_results() to handle it, so the data viewer correctly displays lists of color swatches. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/eval.rs | 106 ++++++++++++++++++++++++- crates/nodebox-gui/src/node_library.rs | 9 ++- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index faedc90c..e51f4313 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -70,6 +70,8 @@ pub enum NodeOutput { Strings(Vec), /// A color value. Color(Color), + /// A list of color values. + Colors(Vec), /// A boolean value. Boolean(bool), /// A list of boolean values. @@ -148,6 +150,7 @@ impl NodeOutput { 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()], @@ -163,7 +166,7 @@ impl NodeOutput { NodeOutput::Int(_) | NodeOutput::Ints(_) => "int", NodeOutput::String(_) | NodeOutput::Strings(_) => "string", NodeOutput::Boolean(_) | NodeOutput::Booleans(_) => "boolean", - NodeOutput::Color(_) => "color", + NodeOutput::Color(_) | NodeOutput::Colors(_) => "color", NodeOutput::Point(_) | NodeOutput::Points(_) => "point", NodeOutput::Path(_) | NodeOutput::Paths(_) => "path", } @@ -179,19 +182,21 @@ impl NodeOutput { NodeOutput::Ints(is) => is.len(), NodeOutput::Strings(ss) => ss.len(), NodeOutput::Booleans(bs) => bs.len(), + NodeOutput::Colors(cs) => cs.len(), _ => 1, } } /// Returns true if this output is a color type. pub fn is_color(&self) -> bool { - matches!(self, NodeOutput::Color(_)) + 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 { + pub fn color_at(&self, index: usize) -> Option { match self { NodeOutput::Color(c) => Some(*c), + NodeOutput::Colors(cs) => cs.get(index).copied(), _ => None, } } @@ -208,6 +213,7 @@ impl NodeOutput { 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(), v => vec![v.clone()], // Single values remain single } } @@ -221,6 +227,7 @@ impl NodeOutput { NodeOutput::Ints(is) => is.len(), NodeOutput::Strings(ss) => ss.len(), NodeOutput::Booleans(bs) => bs.len(), + NodeOutput::Colors(cs) => cs.len(), NodeOutput::None => 0, _ => 1, } @@ -511,6 +518,13 @@ fn collect_results(results: Vec) -> NodeOutput { }).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) + } _ => { // Default: collect as Paths (geometry operations) let paths: Vec = results.into_iter() @@ -3220,4 +3234,90 @@ mod tests { 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(NodePort::int("amount", 3)) + .with_input(NodePort::float("start", 0.0)) + .with_input(NodePort::float("end", 255.0)) + .with_output_range(PortRange::List) + ) + .with_child( + Node::new("rgb_color1") + .with_prototype("color.rgb_color") + .with_input(NodePort::float("red", 0.0)) + .with_input(NodePort::float("green", 0.0)) + .with_input(NodePort::float("blue", 0.0)) + .with_input(NodePort::float("alpha", 255.0)) + .with_input(NodePort::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_port_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/node_library.rs b/crates/nodebox-gui/src/node_library.rs index d072e55d..f50482e7 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -637,7 +637,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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_input(Port::float("range", 255.0)) + .with_output_type(PortType::Color); } "hsb_color" => { node = node @@ -645,13 +646,15 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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_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_input(Port::float("range", 255.0)) + .with_output_type(PortType::Color); } // Core nodes "frame" => { From f60aae3bb3d4b8b77ca1b5d4da6c4a88e61018aa Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 22:17:50 +0100 Subject: [PATCH 034/100] Add drag selection (rubber band) to the network view Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/network_view.rs | 62 ++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index e6590207..1467b549 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -40,6 +40,14 @@ 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, } /// State for dragging a new connection. @@ -91,6 +99,10 @@ 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(), } } @@ -374,6 +386,56 @@ impl NetworkView { } } + // --- 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 { From 159055cc5e2a8f9083dfa3fe022eab2d36643a84 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 22:19:47 +0100 Subject: [PATCH 035/100] Keep viewer tab when rendered node is deleted. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/viewer_pane.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index 5c576d55..51af4c60 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -359,11 +359,19 @@ impl ViewerPane { /// 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 + // 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 is_geometry && !was_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; From 8e1ed574dcf6b5cfc19dfc7cc29cf3fca4812101 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Thu, 12 Feb 2026 22:27:23 +0100 Subject: [PATCH 036/100] Improve parameter panel input field design - Number fields fill available width for easier clicking/dragging - Point widget fields share space equally with 16px gap - White text color for value fields - Edit fields: SLATE_800 background, 4px rounded corners, no border - Blue selection highlight for readable selected text - Consistent 4px text inset and 8px margins - 8px top spacing before first parameter row Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 92 ++++++++++++++++++++++++-------- crates/nodebox-gui/src/theme.rs | 2 + 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index d203f609..506a3565 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -100,6 +100,8 @@ impl ParameterPanel { 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( @@ -324,9 +326,17 @@ impl ParameterPanel { 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); + 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); + }); + 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); + }); + ui.spacing_mut().item_spacing.x = old_spacing; } } Widget::Menu => { @@ -475,13 +485,30 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{:.2}", value), true)); + // Style: no border, darker background, rounded corners, readable selection + let old_selection = ui.visuals().selection.clone(); + let old_corner_radius = ui.visuals().widgets.active.corner_radius; + 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 rounding = egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8); + ui.visuals_mut().widgets.inactive.corner_radius = rounding; + ui.visuals_mut().widgets.active.corner_radius = rounding; + ui.visuals_mut().widgets.hovered.corner_radius = rounding; + let output = egui::TextEdit::singleline(&mut edit_text) - .font(TextStyle::Body) - .text_color(theme::VALUE_TEXT) - .desired_width(60.0) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .desired_width(ui.available_width() - 2.0 * theme::PADDING) + .margin(egui::Margin { left: 4, top: 5, right: 4, bottom: 3 }) + .background_color(theme::SLATE_800) .frame(true) .show(ui); + ui.visuals_mut().selection = old_selection; + ui.visuals_mut().widgets.inactive.corner_radius = old_corner_radius; + ui.visuals_mut().widgets.active.corner_radius = old_corner_radius; + ui.visuals_mut().widgets.hovered.corner_radius = old_corner_radius; + // Select all on first frame if needs_select { if let Some((_, _, _, ref mut sel)) = self.editing { @@ -522,21 +549,22 @@ impl ParameterPanel { output.response.request_focus(); } else { - // Show as draggable text (non-selectable) + // Show as draggable text (non-selectable) — fill available width for easy clicking let text = format!("{:.2}", value); let galley = ui.painter().layout_no_wrap( text.clone(), egui::FontId::proportional(11.0), - theme::VALUE_TEXT, + egui::Color32::WHITE, ); 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 interact_rect = egui::Rect::from_min_size( + rect.min, + egui::vec2(ui.available_width() - theme::PADDING, rect.height()), ); - let response = ui.allocate_rect(text_rect, Sense::click_and_drag()); - ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); + let response = ui.allocate_rect(interact_rect, Sense::click_and_drag()); + let text_pos = egui::pos2(rect.left() + 4.0, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(text_pos, galley, egui::Color32::WHITE); if response.dragged() { // Modifier keys: Shift = x10, Alt = /100 @@ -578,13 +606,30 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{}", value), true)); + // Style: no border, darker background, rounded corners, readable selection + let old_selection = ui.visuals().selection.clone(); + let old_corner_radius = ui.visuals().widgets.active.corner_radius; + 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 rounding = egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8); + ui.visuals_mut().widgets.inactive.corner_radius = rounding; + ui.visuals_mut().widgets.active.corner_radius = rounding; + ui.visuals_mut().widgets.hovered.corner_radius = rounding; + let output = egui::TextEdit::singleline(&mut edit_text) - .font(TextStyle::Body) - .text_color(theme::VALUE_TEXT) - .desired_width(60.0) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) + .text_color(egui::Color32::WHITE) + .desired_width(ui.available_width() - 2.0 * theme::PADDING) + .margin(egui::Margin { left: 4, top: 5, right: 4, bottom: 3 }) + .background_color(theme::SLATE_800) .frame(true) .show(ui); + ui.visuals_mut().selection = old_selection; + ui.visuals_mut().widgets.inactive.corner_radius = old_corner_radius; + ui.visuals_mut().widgets.active.corner_radius = old_corner_radius; + ui.visuals_mut().widgets.hovered.corner_radius = old_corner_radius; + // Select all on first frame if needs_select { if let Some((_, _, _, ref mut sel)) = self.editing { @@ -620,16 +665,17 @@ impl ParameterPanel { let galley = ui.painter().layout_no_wrap( text.clone(), egui::FontId::proportional(11.0), - theme::VALUE_TEXT, + egui::Color32::WHITE, ); 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 interact_rect = egui::Rect::from_min_size( + rect.min, + egui::vec2(ui.available_width() - theme::PADDING, rect.height()), ); - let response = ui.allocate_rect(text_rect, Sense::click_and_drag()); - ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); + let response = ui.allocate_rect(interact_rect, Sense::click_and_drag()); + let text_pos = egui::pos2(rect.left() + 4.0, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(text_pos, galley, egui::Color32::WHITE); if response.dragged() { // Modifier keys: Shift = x10, Alt = /100 @@ -742,6 +788,8 @@ impl ParameterPanel { theme::PORT_VALUE_BACKGROUND, ); + ui.add_space(theme::PADDING); + // Width ui.horizontal(|ui| { ui.set_height(theme::PARAMETER_ROW_HEIGHT); diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 6b7a5761..203f96ce 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -116,6 +116,8 @@ pub const TEXT_EDIT_BG: Color32 = SLATE_700; pub const HOVER_BG: Color32 = SLATE_600; /// 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); // ============================================================================= // SEMANTIC COLORS - Text From 8c2d7e7c2b45526d11120924f2ab1ce1464dc943 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 07:17:23 +0100 Subject: [PATCH 037/100] Fix pixel-perfect text alignment between editing and non-editing states Use non-interactive TextEdit for display mode instead of manual galley painting, ensuring identical text positioning in both states. Click and drag interactions are overlaid via ui.interact(). Edit field uses frameless TextEdit with manual rounded background painting. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 107 +++++++++++++++---------------- 1 file changed, 52 insertions(+), 55 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index 506a3565..b7227b33 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -485,29 +485,29 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{:.2}", value), true)); - // Style: no border, darker background, rounded corners, readable selection + // Frameless TextEdit with manual background for pixel-perfect alignment let old_selection = ui.visuals().selection.clone(); - let old_corner_radius = ui.visuals().widgets.active.corner_radius; 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 rounding = egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8); - ui.visuals_mut().widgets.inactive.corner_radius = rounding; - ui.visuals_mut().widgets.active.corner_radius = rounding; - ui.visuals_mut().widgets.hovered.corner_radius = rounding; + 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() - 2.0 * theme::PADDING) - .margin(egui::Margin { left: 4, top: 5, right: 4, bottom: 3 }) - .background_color(theme::SLATE_800) - .frame(true) + .desired_width(ui.available_width() - 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::SLATE_800, + )); + ui.visuals_mut().selection = old_selection; - ui.visuals_mut().widgets.inactive.corner_radius = old_corner_radius; - ui.visuals_mut().widgets.active.corner_radius = old_corner_radius; - ui.visuals_mut().widgets.hovered.corner_radius = old_corner_radius; // Select all on first frame if needs_select { @@ -549,22 +549,20 @@ impl ParameterPanel { output.response.request_focus(); } else { - // Show as draggable text (non-selectable) — fill available width for easy clicking - let text = format!("{:.2}", value); - let galley = ui.painter().layout_no_wrap( - text.clone(), - egui::FontId::proportional(11.0), - egui::Color32::WHITE, - ); - let rect = ui.available_rect_before_wrap(); - let interact_rect = egui::Rect::from_min_size( - rect.min, - egui::vec2(ui.available_width() - theme::PADDING, rect.height()), - ); + // Non-interactive TextEdit for pixel-perfect alignment with editing state + let mut display_text = format!("{:.2}", value); + 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) + .show(ui); - let response = ui.allocate_rect(interact_rect, Sense::click_and_drag()); - let text_pos = egui::pos2(rect.left() + 4.0, rect.center().y - galley.size().y / 2.0); - ui.painter().galley(text_pos, galley, egui::Color32::WHITE); + // 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()); if response.dragged() { // Modifier keys: Shift = x10, Alt = /100 @@ -606,29 +604,29 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{}", value), true)); - // Style: no border, darker background, rounded corners, readable selection + // Frameless TextEdit with manual background for pixel-perfect alignment let old_selection = ui.visuals().selection.clone(); - let old_corner_radius = ui.visuals().widgets.active.corner_radius; 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 rounding = egui::CornerRadius::same(theme::CORNER_RADIUS_SMALL as u8); - ui.visuals_mut().widgets.inactive.corner_radius = rounding; - ui.visuals_mut().widgets.active.corner_radius = rounding; - ui.visuals_mut().widgets.hovered.corner_radius = rounding; + 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() - 2.0 * theme::PADDING) - .margin(egui::Margin { left: 4, top: 5, right: 4, bottom: 3 }) - .background_color(theme::SLATE_800) - .frame(true) + .desired_width(ui.available_width() - 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::SLATE_800, + )); + ui.visuals_mut().selection = old_selection; - ui.visuals_mut().widgets.inactive.corner_radius = old_corner_radius; - ui.visuals_mut().widgets.active.corner_radius = old_corner_radius; - ui.visuals_mut().widgets.hovered.corner_radius = old_corner_radius; // Select all on first frame if needs_select { @@ -661,21 +659,20 @@ impl ParameterPanel { output.response.request_focus(); } else { - let text = format!("{}", value); - let galley = ui.painter().layout_no_wrap( - text.clone(), - egui::FontId::proportional(11.0), - egui::Color32::WHITE, - ); - let rect = ui.available_rect_before_wrap(); - let interact_rect = egui::Rect::from_min_size( - rect.min, - egui::vec2(ui.available_width() - theme::PADDING, rect.height()), - ); + // Non-interactive TextEdit for pixel-perfect alignment with editing state + let mut display_text = format!("{}", value); + 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) + .show(ui); - let response = ui.allocate_rect(interact_rect, Sense::click_and_drag()); - let text_pos = egui::pos2(rect.left() + 4.0, rect.center().y - galley.size().y / 2.0); - ui.painter().galley(text_pos, galley, egui::Color32::WHITE); + // 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()); if response.dragged() { // Modifier keys: Shift = x10, Alt = /100 From c578f4873624b2f19c48964f4e99d3f86c452f53 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 07:26:03 +0100 Subject: [PATCH 038/100] Add hover effect, fix field widths and right margin alignment Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 32 +++++++++++++++++++++++++++----- crates/nodebox-gui/src/theme.rs | 2 ++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index b7227b33..1a6cdd76 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -326,7 +326,7 @@ impl ParameterPanel { 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 available = ui.available_width(); let old_spacing = ui.spacing().item_spacing.x; ui.spacing_mut().item_spacing.x = 16.0; let field_width = (available - 16.0) / 2.0; @@ -494,7 +494,7 @@ impl ParameterPanel { 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) + .desired_width(ui.available_width() - 2.0 * theme::PADDING) .margin(egui::Margin::symmetric(4, 0)) .frame(false) .show(ui); @@ -551,19 +551,30 @@ impl ParameterPanel { } 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) + .desired_width(ui.available_width() - 2.0 * theme::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.dragged() { // Modifier keys: Shift = x10, Alt = /100 let modifier = ui.input(|i| { @@ -613,7 +624,7 @@ impl ParameterPanel { 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) + .desired_width(ui.available_width() - 2.0 * theme::PADDING) .margin(egui::Margin::symmetric(4, 0)) .frame(false) .show(ui); @@ -661,19 +672,30 @@ impl ParameterPanel { } 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) + .desired_width(ui.available_width() - 2.0 * theme::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.dragged() { // Modifier keys: Shift = x10, Alt = /100 let modifier = ui.input(|i| { diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 203f96ce..96849f03 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -118,6 +118,8 @@ pub const HOVER_BG: Color32 = SLATE_600; 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 (SLATE_800 at ~50% opacity over SLATE_700) +pub const FIELD_HOVER_BG: Color32 = Color32::from_rgb(39, 53, 75); // ============================================================================= // SEMANTIC COLORS - Text From 2abf5b20e4c2919ddaa1dd958622486d958aa10c Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 07:32:54 +0100 Subject: [PATCH 039/100] Fix point widget field spacing to align right edges with single fields Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index 1a6cdd76..0e8688e0 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -328,8 +328,8 @@ impl ParameterPanel { .unwrap_or(false); let available = ui.available_width(); 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.spacing_mut().item_spacing.x = 8.0; + let field_width = (available - 8.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); }); From a5d6f6c8e1444d1d3df35cd957d89c17e2a952a7 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 07:57:28 +0100 Subject: [PATCH 040/100] Fix point widget double margin by separating right padding from TextEdit margin The desired_width calculation now takes a right_padding parameter: - Single fields pass PADDING (8px) for panel edge margin - Point widget fields pass 0 and handle the margin at the outer level Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index 0e8688e0..1e621973 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -193,12 +193,12 @@ impl ParameterPanel { 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); + 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_key, is_editing); + self.show_drag_value_int(ui, value, &port_key, is_editing, theme::PADDING); } } Widget::Toggle => { @@ -326,15 +326,15 @@ impl ParameterPanel { 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(); + let available = ui.available_width() - theme::PADDING; let old_spacing = ui.spacing().item_spacing.x; - ui.spacing_mut().item_spacing.x = 8.0; - let field_width = (available - 8.0) / 2.0; + 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); + 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); + 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; } @@ -469,6 +469,7 @@ impl ParameterPanel { } /// 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, @@ -478,6 +479,7 @@ impl ParameterPanel { speed: f64, port_key: &(String, String), is_editing: bool, + right_padding: f32, ) { if is_editing { // Show text input for direct editing @@ -494,7 +496,7 @@ impl ParameterPanel { 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() - 2.0 * theme::PADDING) + .desired_width(ui.available_width() - theme::PADDING - right_padding) .margin(egui::Margin::symmetric(4, 0)) .frame(false) .show(ui); @@ -558,7 +560,7 @@ impl ParameterPanel { .interactive(false) .frame(false) .margin(egui::Margin::symmetric(4, 0)) - .desired_width(ui.available_width() - 2.0 * theme::PADDING) + .desired_width(ui.available_width() - theme::PADDING - right_padding) .show(ui); // Overlay click+drag sensing on the same rect @@ -608,7 +610,7 @@ impl ParameterPanel { } /// 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) { + fn show_drag_value_int(&mut self, ui: &mut egui::Ui, value: &mut i64, 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() @@ -624,7 +626,7 @@ impl ParameterPanel { 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() - 2.0 * theme::PADDING) + .desired_width(ui.available_width() - theme::PADDING - right_padding) .margin(egui::Margin::symmetric(4, 0)) .frame(false) .show(ui); @@ -679,7 +681,7 @@ impl ParameterPanel { .interactive(false) .frame(false) .margin(egui::Margin::symmetric(4, 0)) - .desired_width(ui.available_width() - 2.0 * theme::PADDING) + .desired_width(ui.available_width() - theme::PADDING - right_padding) .show(ui); // Overlay click+drag sensing on the same rect @@ -839,7 +841,7 @@ impl ParameterPanel { 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); + 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 (state.library.width() - width).abs() > 0.001 { @@ -877,7 +879,7 @@ impl ParameterPanel { 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); + 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 (state.library.height() - height).abs() > 0.001 { From 1f0bc52c72eb9053591111dd9a428f72b66df38f Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 08:27:40 +0100 Subject: [PATCH 041/100] Draw canvas border after geometry so it's visible on top of Vello GPU texture. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/viewer_pane.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index 51af4c60..1f027eff 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -601,14 +601,15 @@ impl ViewerPane { // 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 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); From 14e4745999b50531c02ec9e6a29750f80f40836d Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 08:35:45 +0100 Subject: [PATCH 042/100] Add Tab/Shift+Tab navigation between parameter fields. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 162 ++++++++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 13 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index 634f7a49..9da7bdc9 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -15,6 +15,10 @@ pub struct ParameterPanel { 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)>, } impl Default for ParameterPanel { @@ -30,6 +34,8 @@ impl ParameterPanel { Self { label_width: theme::LABEL_WIDTH, editing: None, + tab_order: Vec::new(), + tab_target: None, } } @@ -64,6 +70,19 @@ impl ParameterPanel { } }; + // 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, @@ -122,6 +141,90 @@ impl ParameterPanel { } } + /// 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() + } + /// Show a single port row with label and value editor. fn show_port_row( &mut self, @@ -263,11 +366,16 @@ impl ParameterPanel { // Commit on enter or focus lost if output.response.lost_focus() { + let tab_pressed = ui.input(|i| i.key_pressed(egui::Key::Tab)); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.editing = None; } else { *value = edit_text; self.editing = None; + if tab_pressed { + let forward = !ui.input(|i| i.modifiers.shift); + self.tab_target = Self::next_tab_stop(&self.tab_order, &port_key, forward); + } } } @@ -522,20 +630,25 @@ impl ParameterPanel { // Commit on enter or focus lost if output.response.lost_focus() { + let tab_pressed = ui.input(|i| i.key_pressed(egui::Key::Tab)); 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 { + 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; + if tab_pressed { + let forward = !ui.input(|i| i.modifiers.shift); + self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); + } } } @@ -623,13 +736,18 @@ impl ParameterPanel { } if output.response.lost_focus() { + let tab_pressed = ui.input(|i| i.key_pressed(egui::Key::Tab)); 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 { + if let Ok(new_val) = edit_text.parse::() { + *value = new_val; + } self.editing = None; + if tab_pressed { + let forward = !ui.input(|i| i.modifiers.shift); + self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); + } } } @@ -737,6 +855,24 @@ impl ParameterPanel { // Apply minimal styling for the panel ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 2.0); + // 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); From 4ff5ece699edd4d7a5556d303f4f71c07c0ad5f9 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 08:49:15 +0100 Subject: [PATCH 043/100] Fix spacebar typing into parameter fields after editing. Make request_focus() conditional on self.editing.is_some() so that after committing a parameter edit (Enter/Tab), focus is released and spacebar triggers panning instead of re-activating the text field. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index 634f7a49..8a1ed50a 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -272,7 +272,9 @@ impl ParameterPanel { } // Request focus on first frame - output.response.request_focus(); + if self.editing.is_some() { + output.response.request_focus(); + } } else { // Show as clickable text let display = if value.is_empty() { "\"\"" } else { value.as_str() }; @@ -539,7 +541,9 @@ impl ParameterPanel { } } - output.response.request_focus(); + if self.editing.is_some() { + output.response.request_focus(); + } } else { // Show as draggable text (non-selectable) let text = format!("{:.2}", value); @@ -633,7 +637,9 @@ impl ParameterPanel { } } - output.response.request_focus(); + if self.editing.is_some() { + output.response.request_focus(); + } } else { let text = format!("{}", value); let galley = ui.painter().layout_no_wrap( From d1e7ec52be7bb5ca4b7f417f3393b38f765e0b98 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 09:37:35 +0100 Subject: [PATCH 044/100] Add canvas background color as a persisted document property. Store background color in NodeLibrary properties as "canvasBackground" hex string, with a gray default matching Java NodeBox. Add color picker widget to the document properties panel and include background color in the Vello renderer cache key so changes update the viewer live. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-core/src/node/library.rs | 17 +++++++++ crates/nodebox-gui/src/panels.rs | 46 +++++++++++++++++++++++++ crates/nodebox-gui/src/state.rs | 3 +- crates/nodebox-gui/src/vello_viewer.rs | 21 +++++++++-- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-core/src/node/library.rs b/crates/nodebox-core/src/node/library.rs index 1a4f80b6..89218818 100644 --- a/crates/nodebox-core/src/node/library.rs +++ b/crates/nodebox-core/src/node/library.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use super::Node; use super::PortType; +use crate::geometry::Color; /// A library of nodes, typically loaded from an .ndbx file. /// @@ -73,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()); diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index e21d4f22..c96f7e16 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use eframe::egui::{self, Sense, TextStyle}; +use nodebox_core::geometry::Color; use nodebox_core::node::{PortType, Widget}; use nodebox_core::Value; use nodebox_port::{FileFilter, Port, PortError, ProjectContext}; @@ -1041,5 +1042,50 @@ impl ParameterPanel { Arc::make_mut(&mut state.library).set_height(height); } }); + + // Background color + 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( + "background".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); + }, + ); + + // 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-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index b35c8ac3..d757a1af 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -59,7 +59,7 @@ impl AppState { show_about: false, geometry: Vec::new(), // Render worker will populate selected_node: None, - background_color: Color::WHITE, + 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, @@ -110,6 +110,7 @@ impl AppState { // 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; diff --git a/crates/nodebox-gui/src/vello_viewer.rs b/crates/nodebox-gui/src/vello_viewer.rs index 512c8659..33b7402d 100644 --- a/crates/nodebox-gui/src/vello_viewer.rs +++ b/crates/nodebox-gui/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); } } From 1dd2b9c549ea48ed70e52ece97679ffe07ca99db Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 09:39:03 +0100 Subject: [PATCH 045/100] Switch color theme from Slate to Zinc, lighten UI by one tint. Replace Tailwind Slate palette with Zinc (neutral gray with subtle blue-violet undertone). Shift all semantic tokens one step lighter for a brighter UI. Fix pane header bottom border overlap by painting on foreground layer. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/address_bar.rs | 6 +- crates/nodebox-gui/src/animation_bar.rs | 36 ++-- crates/nodebox-gui/src/components.rs | 33 ++-- crates/nodebox-gui/src/theme.rs | 226 ++++++++++++------------ crates/nodebox-gui/src/viewer_pane.rs | 4 +- 5 files changed, 155 insertions(+), 150 deletions(-) diff --git a/crates/nodebox-gui/src/address_bar.rs b/crates/nodebox-gui/src/address_bar.rs index 7a2a9fd5..54260a93 100644 --- a/crates/nodebox-gui/src/address_bar.rs +++ b/crates/nodebox-gui/src/address_bar.rs @@ -148,13 +148,13 @@ impl AddressBar { // Determine button color based on rendering state let button_color = if !self.is_rendering { // Not rendering: subtle (disabled appearance) - theme::SLATE_700 + theme::ZINC_600 } else if self.render_elapsed_secs < STOP_BUTTON_HIGHLIGHT_THRESHOLD_SECS { // Rendering but less than threshold: subtle - theme::SLATE_700 + theme::ZINC_600 } else { // Rendering and past threshold: prominent - theme::SLATE_300 + theme::ZINC_200 }; // Draw the stop button (circle with square inside) diff --git a/crates/nodebox-gui/src/animation_bar.rs b/crates/nodebox-gui/src/animation_bar.rs index d43ad365..b03b7928 100644 --- a/crates/nodebox-gui/src/animation_bar.rs +++ b/crates/nodebox-gui/src/animation_bar.rs @@ -293,36 +293,36 @@ impl AnimationBar { } /// Styled DragValue that follows the style guide. - /// Uses SLATE_800 for subtle elevation against SLATE_900 bar background. + /// Uses ZINC_700 for subtle elevation against ZINC_800 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; @@ -354,23 +354,23 @@ impl AnimationBar { // 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_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.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_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.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_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.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_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; @@ -391,7 +391,7 @@ impl AnimationBar { 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 }; diff --git a/crates/nodebox-gui/src/components.rs b/crates/nodebox-gui/src/components.rs index 533c61ae..80483403 100644 --- a/crates/nodebox-gui/src/components.rs +++ b/crates/nodebox-gui/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,18 @@ pub fn draw_pane_header_with_title( egui::Stroke::new(1.0, theme::TEXT_DISABLED), ); - // Bottom border (1px dark line) - draw at bottom edge - ui.painter().line_segment( + // Bottom border (1px dark line) - painted on foreground layer so content + // below (canvas, table headers) cannot paint over it. + let fg_painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Foreground, + ui.id().with("header_border"), + )); + fg_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) @@ -287,7 +292,7 @@ pub fn header_segmented_control( ui.painter().rect_filled( selected_rect, 0.0, - theme::SLATE_700, + theme::ZINC_600, ); } @@ -374,29 +379,29 @@ pub fn header_zoom_control( 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; @@ -462,7 +467,7 @@ pub fn header_icon_button( ui.painter().rect_filled( button_rect, 0.0, - theme::SLATE_700, + theme::ZINC_600, ); } diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 6b7a5761..253fcf5b 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/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) @@ -101,19 +101,19 @@ 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; +pub const HOVER_BG: Color32 = ZINC_500; /// Selection background (visible violet with good text contrast) pub const SELECTION_BG: Color32 = VIOLET_800; @@ -122,30 +122,30 @@ pub const SELECTION_BG: Color32 = VIOLET_800; // ============================================================================= /// 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_700; +pub const WIDGET_INACTIVE_BG: Color32 = ZINC_600; /// Widget hovered background -pub const WIDGET_HOVERED_BG: Color32 = SLATE_600; +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 @@ -175,26 +175,26 @@ pub const LABEL_WIDTH: f32 = 112.0; // ============================================================================= /// Zebra stripe: even row background (matches panel bg) -pub const TABLE_ROW_EVEN: Color32 = SLATE_900; -/// Zebra stripe: odd row alternating background (between SLATE_900 and SLATE_800) -pub const TABLE_ROW_ODD: Color32 = Color32::from_rgb(19, 29, 50); +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 = SLATE_800; +pub const TABLE_HEADER_BG: Color32 = ZINC_700; /// Table header text color -pub const TABLE_HEADER_TEXT: Color32 = SLATE_300; +pub const TABLE_HEADER_TEXT: Color32 = ZINC_200; /// Table cell text color -pub const TABLE_CELL_TEXT: Color32 = SLATE_200; +pub const TABLE_CELL_TEXT: Color32 = ZINC_100; /// Index column text color (subdued) -pub const TABLE_INDEX_TEXT: Color32 = SLATE_400; +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; @@ -260,34 +260,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_700; +pub const PORT_VALUE_BACKGROUND: Color32 = ZINC_600; // 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; @@ -298,17 +298,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); @@ -318,16 +318,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); @@ -335,8 +335,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) @@ -346,20 +346,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 @@ -406,7 +406,7 @@ 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; @@ -416,39 +416,39 @@ pub fn configure_style(ctx: &egui::Context) { // 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 = SLATE_800; - visuals.widgets.noninteractive.weak_bg_fill = SLATE_800; + 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 = SLATE_700; - visuals.widgets.inactive.weak_bg_fill = SLATE_700; - visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, SLATE_200); + 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 = SLATE_600; - visuals.widgets.hovered.weak_bg_fill = SLATE_600; - visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, SLATE_100); + 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 = SLATE_600; - visuals.widgets.active.weak_bg_fill = SLATE_600; - visuals.widgets.active.fg_stroke = Stroke::new(1.0, SLATE_100); + 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 = SLATE_600; - visuals.widgets.open.weak_bg_fill = SLATE_600; - visuals.widgets.open.fg_stroke = Stroke::new(1.0, SLATE_200); + 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 with visible text) diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index 51af4c60..5283838a 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -936,7 +936,7 @@ impl ViewerPane { ui.painter().rect_stroke( swatch_rect, 0.0, - Stroke::new(1.0, theme::SLATE_600), + Stroke::new(1.0, theme::ZINC_500), egui::StrokeKind::Inside, ); } @@ -1257,7 +1257,7 @@ impl ViewerPane { ui.painter().rect_stroke( swatch_rect, 0.0, - Stroke::new(1.0, theme::SLATE_600), + Stroke::new(1.0, theme::ZINC_500), egui::StrokeKind::Inside, ); From 8c6dfcbcea8f6e3b9871898a71bc56f36ec451aa Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 09:42:11 +0100 Subject: [PATCH 046/100] Remove gap between parameters header and content rows. Set item_spacing to zero before drawing the pane header, then restore row spacing afterward so the header sits flush against the content. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/panels.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index 634f7a49..338e0d54 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -41,8 +41,8 @@ impl ParameterPanel { port: &dyn Port, project_context: &ProjectContext, ) { - // Apply minimal styling for the panel - ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 2.0); + // Zero spacing so header sits flush against content + ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0); if let Some(ref node_name) = state.selected_node.clone() { // First, collect connected ports while we only have immutable borrow @@ -71,6 +71,9 @@ impl ParameterPanel { 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 @@ -734,12 +737,12 @@ impl ParameterPanel { /// 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); + // 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 From 747fb507ca58be25a42274e39c588c306ed58dcd Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 10:52:11 +0100 Subject: [PATCH 047/100] Use 0-based viewBox and explicit dimensions in SVG export for compatibility. Replace negative viewBox with translate group and percentage-based rect dimensions with explicit numeric values. Fixes rendering in apps like Acorn that don't handle negative viewBox or percentage attributes. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-svg/src/renderer.rs | 45 ++++++++++++++++++------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/crates/nodebox-svg/src/renderer.rs b/crates/nodebox-svg/src/renderer.rs index 7fd4fea3..d2d60291 100644 --- a/crates/nodebox-svg/src/renderer.rs +++ b/crates/nodebox-svg/src/renderer.rs @@ -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(); } @@ -455,7 +459,7 @@ mod tests { let svg = render_to_svg_with_options(&[], &options); // Should not have background rect - assert!(!svg.contains(r#" Date: Fri, 13 Feb 2026 11:06:16 +0100 Subject: [PATCH 048/100] Style panel splitter with 2px line and hover/drag feedback. Use egui's built-in separator styling: ZINC_900 normal, ZINC_700 on hover, ZINC_300 when dragging. Temporarily override widget strokes for the side panel, then restore for the rest of the UI. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/app.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index a9440b84..56cc634d 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -675,6 +675,15 @@ impl eframe::App for NodeBoxApp { } // 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) @@ -746,6 +755,13 @@ impl eframe::App for NodeBoxApp { }); }); + // 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)) From be4c54d14a477a0a5c964eb3a7abc77fec411956 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 11:13:40 +0100 Subject: [PATCH 049/100] Rename panels.rs to parameter_panel.rs to match its content. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/app.rs | 2 +- crates/nodebox-gui/src/lib.rs | 2 +- crates/nodebox-gui/src/{panels.rs => parameter_panel.rs} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename crates/nodebox-gui/src/{panels.rs => parameter_panel.rs} (100%) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index a9440b84..80b4f204 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -14,7 +14,7 @@ use crate::native_menu::{MenuAction, NativeMenuHandle}; use crate::recent_files::RecentFiles; use crate::network_view::{NetworkAction, NetworkView}; use crate::node_selection_dialog::NodeSelectionDialog; -use crate::panels::ParameterPanel; +use crate::parameter_panel::ParameterPanel; use crate::render_worker::{RenderResult, RenderState, RenderWorkerHandle}; use crate::state::AppState; use crate::theme; diff --git a/crates/nodebox-gui/src/lib.rs b/crates/nodebox-gui/src/lib.rs index 66d22e0b..aa929aa9 100644 --- a/crates/nodebox-gui/src/lib.rs +++ b/crates/nodebox-gui/src/lib.rs @@ -33,7 +33,7 @@ mod network_view; mod node_library; mod node_selection_dialog; mod pan_zoom; -mod panels; +mod parameter_panel; pub mod render_worker; pub mod state; mod theme; diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/parameter_panel.rs similarity index 100% rename from crates/nodebox-gui/src/panels.rs rename to crates/nodebox-gui/src/parameter_panel.rs From 91639fbc7ec546eb744edcf80b602d2d4516a12e Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 11:35:30 +0100 Subject: [PATCH 050/100] Draw header bottom border on panel layer instead of foreground. The foreground layer caused the 1px border line to render on top of popups and dialogs like the color picker and node selection dialog. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/components.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/nodebox-gui/src/components.rs b/crates/nodebox-gui/src/components.rs index 80483403..6f081813 100644 --- a/crates/nodebox-gui/src/components.rs +++ b/crates/nodebox-gui/src/components.rs @@ -72,13 +72,8 @@ pub fn draw_pane_header_with_title( egui::Stroke::new(1.0, theme::TEXT_DISABLED), ); - // Bottom border (1px dark line) - painted on foreground layer so content - // below (canvas, table headers) cannot paint over it. - let fg_painter = ui.ctx().layer_painter(egui::LayerId::new( - egui::Order::Foreground, - ui.id().with("header_border"), - )); - fg_painter.line_segment( + // 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), From 79ac0069efe6df8eb841cf8135fd1625583cf538 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 11:44:13 +0100 Subject: [PATCH 051/100] Make node selection dialog modal so background panels don't respond. Add a full-screen backdrop area behind the dialog that captures pointer events and closes on click-outside. Switch pan_zoom scroll check to layer-aware rect_contains_pointer so overlapping dialogs block zoom. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/node_selection_dialog.rs | 15 ++++++++++++++- crates/nodebox-gui/src/pan_zoom.rs | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index 67394d81..d2a8c8af 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -1,6 +1,6 @@ //! Modal node selection dialog with search and category filtering. -use eframe::egui::{self, Color32, Key, Vec2}; +use eframe::egui::{self, Color32, Id, Key, Vec2}; use nodebox_core::geometry::Point; use nodebox_core::node::{Node, NodeLibrary}; use crate::icon_cache::IconCache; @@ -161,6 +161,19 @@ impl NodeSelectionDialog { } }); + // Full-screen modal backdrop: absorbs all pointer events so panels + // behind the dialog don't respond to clicks or scroll wheel. + let screen = ctx.content_rect(); + egui::Area::new(Id::new("node_dialog_backdrop")) + .order(egui::Order::Middle) + .fixed_pos(screen.min) + .show(ctx, |ui| { + let response = ui.allocate_response(screen.size(), egui::Sense::click_and_drag()); + if response.clicked() { + should_close = true; + } + }); + // Modal window - clean Figma-like styling let dialog_frame = egui::Frame::NONE .fill(theme::PANEL_BG) diff --git a/crates/nodebox-gui/src/pan_zoom.rs b/crates/nodebox-gui/src/pan_zoom.rs index e0d3245e..42c66687 100644 --- a/crates/nodebox-gui/src/pan_zoom.rs +++ b/crates/nodebox-gui/src/pan_zoom.rs @@ -57,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; From c522c9af38daee113f4606a6bb15865707ed1969 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 11:49:26 +0100 Subject: [PATCH 052/100] Add draggable labels in parameter panel. Label text for float, int, angle and point parameters can now be dragged to change values (with shift/alt modifiers). Clicking a label activates text editing; for points the typed value applies to both x and y. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/parameter_panel.rs | 140 ++++++++++++++++++---- 1 file changed, 119 insertions(+), 21 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index 5c9ca436..734d4651 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -20,6 +20,11 @@ pub struct ParameterPanel { 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, } impl Default for ParameterPanel { @@ -37,6 +42,8 @@ impl ParameterPanel { editing: None, tab_order: Vec::new(), tab_target: None, + label_edit_apply_both: false, + label_edit_committed_value: None, } } @@ -228,6 +235,20 @@ impl ParameterPanel { String::new() } + /// 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 + } + }) + } + /// Show a single port row with label and value editor. fn show_port_row( &mut self, @@ -238,6 +259,30 @@ impl ParameterPanel { io_port: &dyn Port, 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; + + // 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); @@ -247,9 +292,10 @@ impl ParameterPanel { egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); + let bg_idx = ui.painter().add(egui::Shape::Noop); // Use painter to draw text directly (non-selectable) let galley = ui.painter().layout_no_wrap( - port.name.clone(), + port_name.clone(), egui::FontId::proportional(11.0), theme::TEXT_NORMAL, ); @@ -259,6 +305,30 @@ impl ParameterPanel { rect.center().y - galley.size().y / 2.0, ); ui.painter().galley(pos, galley, theme::TEXT_NORMAL); + + // Overlay drag interaction on the full label area + if is_label_draggable { + let full_rect = ui.max_rect(); + let interact_id = ui.id().with(("label_drag", &port_name)); + 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.dragged() { + label_drag_delta_x = response.drag_delta().x; + } + if response.clicked() { + if let Some(ref state) = label_click_edit_state { + self.editing = Some((state.0.clone(), state.1.clone(), state.2.clone(), true)); + if label_click_is_point { + self.label_edit_apply_both = true; + } + } + } + } }, ); @@ -275,8 +345,51 @@ impl ParameterPanel { ui.painter().galley(pos, galley, theme::TEXT_DISABLED); } else { self.show_port_editor(ui, port, node_name, 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_delta_x != 0.0 { + let modifier = Self::drag_modifier(ui); + let delta = label_drag_delta_x as f64 * modifier; + + match port.widget { + Widget::Float | Widget::Angle => { + if let Value::Float(ref mut value) = port.value { + *value += 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 += delta as i64; + } + } + Widget::Point => { + if let Value::Point(ref mut point) = port.value { + point.x += delta; + point.y += delta; + } + } + _ => {} + } + } } /// Show the editor widget for a port value - minimal style with no borders. @@ -675,6 +788,9 @@ impl ParameterPanel { clamped = clamped.min(max_val); } *value = clamped; + if self.label_edit_apply_both { + self.label_edit_committed_value = Some(clamped); + } } self.editing = None; if tab_pressed { @@ -715,16 +831,7 @@ impl ParameterPanel { } 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 modifier = Self::drag_modifier(ui); let delta = response.drag_delta().x as f64 * speed * modifier; *value += delta; if let Some(min_val) = min { @@ -843,16 +950,7 @@ impl ParameterPanel { } 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 modifier = Self::drag_modifier(ui); let delta = response.drag_delta().x as f64 * modifier; *value += delta as i64; } From 8851952a23cfa8f0da808fb8418938cfeb3ff840 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 11:51:06 +0100 Subject: [PATCH 053/100] Prevent crash when polygon sides or similar int params are negative. Clamp negative i64 values before casting to u32 in eval, add min/max support to the int drag widget, and set appropriate minimums on polygon sides, star points, grid rows/columns, copy copies, and line points. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/eval.rs | 16 ++++++++-------- crates/nodebox-gui/src/node_library.rs | 12 ++++++------ crates/nodebox-gui/src/panels.rs | 19 ++++++++++++++++--- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index a4649477..99db9c9b 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -956,21 +956,21 @@ fn execute_node( "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 points = get_int(inputs, "points", 2).max(0) as u32; let path = nodebox_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) as u32; + let sides = get_int(inputs, "sides", 6).max(0) as u32; let align = get_bool(inputs, "align", true); let path = nodebox_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) as u32; + 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 = nodebox_ops::star(position, points, outer, inner); @@ -1046,7 +1046,7 @@ fn execute_node( } "corevector.copy" => { let shape = require_path(inputs, node_name, "shape")?; - let copies = get_int(inputs, "copies", 1) as u32; + let copies = get_int(inputs, "copies", 1).max(0) 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); @@ -1126,8 +1126,8 @@ fn execute_node( // Grid of points "corevector.grid" => { - let columns = get_int(inputs, "columns", 3) as u32; - let rows = get_int(inputs, "rows", 3) as u32; + 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 @@ -1197,7 +1197,7 @@ fn execute_node( 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 points = get_int(inputs, "points", 2).max(0) as u32; let path = nodebox_ops::line_angle(position, angle, distance, points); Ok(NodeOutput::Path(path)) } @@ -1707,7 +1707,7 @@ fn execute_node( } "string.as_number_list" => { let s = get_string(inputs, "string", ""); - let radix = get_int(inputs, "radix", 10) as u32; + let radix = get_int(inputs, "radix", 10).max(0) as u32; let padding = get_bool(inputs, "padding", true); Ok(NodeOutput::Strings(nodebox_ops::string::as_number_list(&s, radix, padding))) } diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index eaa4c62d..28b45515 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -414,19 +414,19 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, 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_input(Port::int("points", 2).with_min(0.0)); } "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::int("sides", 3).with_min(3.0)) .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::int("points", 20).with_min(2.0)) .with_input(Port::float("outer", 200.0)) .with_input(Port::float("inner", 100.0)); } @@ -452,8 +452,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, } "grid" => { node = node - .with_input(Port::int("columns", 10)) - .with_input(Port::int("rows", 10)) + .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)) @@ -480,7 +480,7 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, "copy" => { node = node .with_input(Port::geometry("shape")) - .with_input(Port::int("copies", 1)) + .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"), diff --git a/crates/nodebox-gui/src/panels.rs b/crates/nodebox-gui/src/panels.rs index 83e20e85..ae1e8b52 100644 --- a/crates/nodebox-gui/src/panels.rs +++ b/crates/nodebox-gui/src/panels.rs @@ -306,7 +306,7 @@ impl ParameterPanel { } Widget::Int => { if let Value::Int(ref mut value) = port.value { - self.show_drag_value_int(ui, value, &port_key, is_editing, theme::PADDING); + self.show_drag_value_int(ui, value, port.min, port.max, &port_key, is_editing, theme::PADDING); } } Widget::Toggle => { @@ -750,7 +750,7 @@ impl ParameterPanel { } /// 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, right_padding: f32) { + 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() @@ -805,7 +805,14 @@ impl ParameterPanel { self.editing = None; } else { if let Ok(new_val) = edit_text.parse::() { - *value = new_val; + 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 tab_pressed { @@ -858,6 +865,12 @@ impl ParameterPanel { }); let delta = response.drag_delta().x as f64 * modifier; *value += delta as i64; + 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() { From 33501e7abe3e61f44ae046b2e51175b7b26c9c25 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 11:57:42 +0100 Subject: [PATCH 054/100] Remove modal backdrop that stole focus from the node dialog. The layer-aware rect_contains_pointer check in pan_zoom is sufficient to block background scroll zoom when the dialog is open. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/node_selection_dialog.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index d2a8c8af..67394d81 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -1,6 +1,6 @@ //! Modal node selection dialog with search and category filtering. -use eframe::egui::{self, Color32, Id, Key, Vec2}; +use eframe::egui::{self, Color32, Key, Vec2}; use nodebox_core::geometry::Point; use nodebox_core::node::{Node, NodeLibrary}; use crate::icon_cache::IconCache; @@ -161,19 +161,6 @@ impl NodeSelectionDialog { } }); - // Full-screen modal backdrop: absorbs all pointer events so panels - // behind the dialog don't respond to clicks or scroll wheel. - let screen = ctx.content_rect(); - egui::Area::new(Id::new("node_dialog_backdrop")) - .order(egui::Order::Middle) - .fixed_pos(screen.min) - .show(ctx, |ui| { - let response = ui.allocate_response(screen.size(), egui::Sense::click_and_drag()); - if response.clicked() { - should_close = true; - } - }); - // Modal window - clean Figma-like styling let dialog_frame = egui::Frame::NONE .fill(theme::PANEL_BG) From 7b5ed056ac5716358f0ede7ce0124c53fbd1096c Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Fri, 13 Feb 2026 12:03:14 +0100 Subject: [PATCH 055/100] Extract shared show_draggable_label for port rows and document properties. Co-Authored-By: Claude Opus 4.6 --- crates/nodebox-gui/src/parameter_panel.rs | 192 ++++++++++------------ 1 file changed, 90 insertions(+), 102 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index 734d4651..03522548 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -249,6 +249,65 @@ impl ParameterPanel { }) } + /// Draw a right-aligned label in a fixed-width column, optionally with drag-to-adjust + /// and click-to-edit interaction. + /// + /// Returns the drag delta in pixels (0.0 if not draggable or not dragged). + fn show_draggable_label( + &mut self, + ui: &mut egui::Ui, + label: &str, + click_edit_state: Option<(String, String, String)>, + set_apply_both: bool, + ) -> f32 { + let label_width = self.label_width; + let mut drag_delta_x: f32 = 0.0; + 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.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 + } + /// Show a single port row with label and value editor. fn show_port_row( &mut self, @@ -287,49 +346,8 @@ impl ParameterPanel { 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); - let bg_idx = ui.painter().add(egui::Shape::Noop); - // 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); - - // Overlay drag interaction on the full label area - if is_label_draggable { - let full_rect = ui.max_rect(); - let interact_id = ui.id().with(("label_drag", &port_name)); - 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.dragged() { - label_drag_delta_x = response.drag_delta().x; - } - if response.clicked() { - if let Some(ref state) = label_click_edit_state { - self.editing = Some((state.0.clone(), state.1.clone(), state.2.clone(), true)); - if label_click_is_point { - self.label_edit_apply_both = true; - } - } - } - } - }, + label_drag_delta_x = self.show_draggable_label( + ui, &port_name, label_click_edit_state, label_click_is_point, ); // Value editor @@ -1072,31 +1090,19 @@ impl ParameterPanel { ui.add_space(theme::PADDING); // Width + let current_width = state.library.width(); + let mut width_label_drag: f32 = 0.0; 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); - }, + width_label_drag = self.show_draggable_label( + ui, "width", + Some(("__document__".to_string(), "width".to_string(), format!("{:.2}", current_width))), + false, ); // Value - let mut width = state.library.width(); + 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) @@ -1104,37 +1110,31 @@ impl ParameterPanel { 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 (state.library.width() - width).abs() > 0.001 { + if (current_width - width).abs() > 0.001 { Arc::make_mut(&mut state.library).set_width(width); } }); + if width_label_drag != 0.0 { + let modifier = Self::drag_modifier(ui); + let delta = width_label_drag as f64 * modifier; + let new_width = (state.library.width() + 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; 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); - }, + height_label_drag = self.show_draggable_label( + ui, "height", + Some(("__document__".to_string(), "height".to_string(), format!("{:.2}", current_height))), + false, ); // Value - let mut height = state.library.height(); + 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) @@ -1142,34 +1142,22 @@ impl ParameterPanel { 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 (state.library.height() - height).abs() > 0.001 { + if (current_height - height).abs() > 0.001 { Arc::make_mut(&mut state.library).set_height(height); } }); + if height_label_drag != 0.0 { + let modifier = Self::drag_modifier(ui); + let delta = height_label_drag as f64 * modifier; + let new_height = (state.library.height() + 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); - // 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( - "background".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); - }, - ); + self.show_draggable_label(ui, "background", None, false); // Color widget let color = state.background_color; From 66b149724d2a603bf323a3850483aae3bcab116a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 10:36:28 +0000 Subject: [PATCH 056/100] Add missing math, string, list, and core categories to node selection dialog. https://claude.ai/code/session_01VmHyqEPs7QCd6HM9D2iJSV --- crates/nodebox-gui/src/node_selection_dialog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index 67394d81..db1863dc 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -8,7 +8,7 @@ use crate::node_library::{NodeTemplate, NODE_TEMPLATES, create_node_from_templat 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"]; /// The modal node selection dialog. pub struct NodeSelectionDialog { From a610f155b7918b60251a533f2a6c06d865b93030 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 10:36:58 +0000 Subject: [PATCH 057/100] Snap float drag values to integers by default, allow fractional only with Alt. Adds a drag_accumulator that collects sub-pixel deltas across frames and only commits the integer portion to float values. Holding Alt (fine mode) bypasses the snapping and applies full fractional deltas. The same accumulator also fixes int drags silently dropping sub-pixel movements. https://claude.ai/code/session_017d196uGtekSRhtuuTzMvdP --- crates/nodebox-gui/src/parameter_panel.rs | 156 +++++++++++++++++----- 1 file changed, 120 insertions(+), 36 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index af07f085..26d161b1 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -25,6 +25,8 @@ pub struct ParameterPanel { 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, } impl Default for ParameterPanel { @@ -44,6 +46,7 @@ impl ParameterPanel { tab_target: None, label_edit_apply_both: false, label_edit_committed_value: None, + drag_accumulator: 0.0, } } @@ -255,16 +258,17 @@ impl ParameterPanel { /// Draw a right-aligned label in a fixed-width column, optionally with drag-to-adjust /// and click-to-edit interaction. /// - /// Returns the drag delta in pixels (0.0 if not draggable or not dragged). + /// 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 { + ) -> (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( @@ -295,6 +299,9 @@ impl ParameterPanel { )); ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } + if response.drag_started() { + drag_started = true; + } if response.dragged() { drag_delta_x = response.drag_delta().x; } @@ -308,7 +315,7 @@ impl ParameterPanel { }, ); - drag_delta_x + (drag_delta_x, drag_started) } /// Show a single port row with label and value editor. @@ -325,6 +332,7 @@ impl ParameterPanel { && 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 { @@ -349,7 +357,7 @@ impl ParameterPanel { ui.set_height(theme::PARAMETER_ROW_HEIGHT); // Fixed-width label, right-aligned (non-selectable) - label_drag_delta_x = self.show_draggable_label( + (label_drag_delta_x, label_drag_started) = self.show_draggable_label( ui, &port_name, label_click_edit_state, label_click_is_point, ); @@ -381,34 +389,51 @@ impl ParameterPanel { }); // 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); - let delta = label_drag_delta_x as f64 * modifier; + self.drag_accumulator += label_drag_delta_x as f64 * modifier; - match port.widget { - Widget::Float | Widget::Angle => { - if let Value::Float(ref mut value) = port.value { - *value += 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); + 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 += delta as i64; + 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 += delta; - point.y += delta; + Widget::Point => { + if let Value::Point(ref mut point) = port.value { + point.x += apply_delta; + point.y += apply_delta; + } } + _ => {} } - _ => {} } } } @@ -851,10 +876,28 @@ impl ParameterPanel { )); } + if response.drag_started() { + self.drag_accumulator = 0.0; + } if response.dragged() { let modifier = Self::drag_modifier(ui); - let delta = response.drag_delta().x as f64 * speed * modifier; - *value += delta; + 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); } @@ -977,10 +1020,17 @@ impl ParameterPanel { )); } + if response.drag_started() { + self.drag_accumulator = 0.0; + } if response.dragged() { let modifier = Self::drag_modifier(ui); - let delta = response.drag_delta().x as f64 * modifier; - *value += delta as i64; + 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); } @@ -1109,10 +1159,11 @@ impl ParameterPanel { // 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 = self.show_draggable_label( + (width_label_drag, width_drag_started) = self.show_draggable_label( ui, "width", Some(("__document__".to_string(), "width".to_string(), format!("{:.2}", current_width))), false, @@ -1131,20 +1182,37 @@ impl ParameterPanel { 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); - let delta = width_label_drag as f64 * modifier; - let new_width = (state.library.width() + delta).max(1.0); - Arc::make_mut(&mut state.library).set_width(new_width); + 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 = self.show_draggable_label( + (height_label_drag, height_drag_started) = self.show_draggable_label( ui, "height", Some(("__document__".to_string(), "height".to_string(), format!("{:.2}", current_height))), false, @@ -1163,11 +1231,27 @@ impl ParameterPanel { 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); - let delta = height_label_drag as f64 * modifier; - let new_height = (state.library.height() + delta).max(1.0); - Arc::make_mut(&mut state.library).set_height(new_height); + 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 From 5a7d85c0fab6d9b974661c55cafa8d6addf991cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 10:38:51 +0000 Subject: [PATCH 058/100] Make list processing nodes accept any input type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List nodes (count, first, reverse, shuffle, slice) were hardcoded to accept only PortType::Geometry inputs. Changed them to use PortType::List which acts as a universal connector — any type can connect to a List input, and List outputs can connect to any downstream input. - Updated is_compatible() to treat PortType::List as a generic type - Changed list node input ports from Port::geometry to PortType::List - Set correct output types (count→Int, first→List, others→List+List) - Added NodeOutput::Colors support to all list node eval match arms https://claude.ai/code/session_01NoFFq96einUnh1inhLHGAr --- crates/nodebox-core/src/node/port.rs | 25 +++++++++++++++++++++++++ crates/nodebox-gui/src/eval.rs | 13 +++++++++++++ crates/nodebox-gui/src/node_library.rs | 26 ++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-core/src/node/port.rs b/crates/nodebox-core/src/node/port.rs index 42b5277c..53e1fbae 100644 --- a/crates/nodebox-core/src/node/port.rs +++ b/crates/nodebox-core/src/node/port.rs @@ -79,6 +79,16 @@ 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; + } + // Everything can be converted to a string if matches!(input_type, PortType::String) { return true; @@ -388,6 +398,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-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 99db9c9b..948aa45c 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -1724,6 +1724,7 @@ fn execute_node( 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)), } } @@ -1736,6 +1737,7 @@ fn execute_node( 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), } } @@ -1748,6 +1750,7 @@ fn execute_node( 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), } } @@ -1760,6 +1763,7 @@ fn execute_node( 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), } } @@ -1772,6 +1776,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::rest(v))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::rest(v))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::rest(v))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::rest(v))), _ => Ok(NodeOutput::None), } } @@ -1784,6 +1789,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::reverse(v))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::reverse(v))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::reverse(v))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::reverse(v))), _ => Ok(NodeOutput::None), } } @@ -1799,6 +1805,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::slice(v, start_index, size, invert))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::slice(v, start_index, size, invert))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::slice(v, start_index, size, invert))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::slice(v, start_index, size, invert))), _ => Ok(NodeOutput::None), } } @@ -1812,6 +1819,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::shift(v, amount))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::shift(v, amount))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::shift(v, amount))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::shift(v, amount))), _ => Ok(NodeOutput::None), } } @@ -1826,6 +1834,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::repeat(v, amount, per_item))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::repeat(v, amount, per_item))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::repeat(v, amount, per_item))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::repeat(v, amount, per_item))), _ => Ok(NodeOutput::None), } } @@ -1849,6 +1858,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::shuffle(v, seed))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::shuffle(v, seed))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::shuffle(v, seed))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::shuffle(v, seed))), _ => Ok(NodeOutput::None), } } @@ -1863,6 +1873,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::pick(v, amount, seed))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::pick(v, amount, seed))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::pick(v, amount, seed))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::pick(v, amount, seed))), _ => Ok(NodeOutput::None), } } @@ -1876,6 +1887,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::cull(v, &booleans))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::cull(v, &booleans))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::cull(v, &booleans))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::cull(v, &booleans))), _ => Ok(NodeOutput::None), } } @@ -1889,6 +1901,7 @@ fn execute_node( Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::take_every(v, n))), Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::take_every(v, n))), Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::take_every(v, n))), + Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::take_every(v, n))), _ => Ok(NodeOutput::None), } } diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 28b45515..b7e9f606 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -637,16 +637,34 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_output_type(PortType::String) .with_output_range(PortRange::List); } - // List nodes + // List nodes — accept any list type via PortType::List "count" | "first" | "reverse" | "shuffle" | "slice" => { - node = node.with_input(Port::geometry("list").with_port_range(PortRange::List)); + node = node.with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)); match template.name { - "shuffle" => { node = node.with_input(Port::int("seed", 0)); } + "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_input(Port::boolean("invert", false)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); } _ => {} } From b06242107db0759ee9ff2037867ad75f9a43ad6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 10:51:08 +0000 Subject: [PATCH 059/100] Use pointing hand cursor for category buttons in node selection dialog. https://claude.ai/code/session_01VmHyqEPs7QCd6HM9D2iJSV --- crates/nodebox-gui/src/node_selection_dialog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index db1863dc..86d7087b 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -247,7 +247,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" { From 4d2f4e0dc02c6c300c60c4f3971b4ac6cc8e3699 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 10:54:40 +0000 Subject: [PATCH 060/100] Sort node search results by match quality. Replace boolean fuzzy_match with scored match_score that ranks results: exact match (100) > prefix (80) > substring (60) > description (40) > subsequence (20). Typing "sample" now shows "sample" before "resample". https://claude.ai/code/session_01RP44EaNqgXdD59PWS4EqCe --- .../nodebox-gui/src/node_selection_dialog.rs | 134 +++++++++++++++--- 1 file changed, 114 insertions(+), 20 deletions(-) diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index 67394d81..bb4275db 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -18,8 +18,8 @@ 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. @@ -68,6 +68,7 @@ impl NodeSelectionDialog { } /// 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(); @@ -80,39 +81,53 @@ 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); + } + + // Tier 3: Name contains query (substring match) + if name.contains(query) { + return Some(60); } - // Contains match - if name.contains(query) || desc.contains(query) { - return true; + // Tier 4: Description contains query + if desc.contains(query) { + return Some(40); } - // First letters match (e.g., "rc" matches "rect create") + // Tier 5: Subsequence match on name (fuzzy) let name_chars: Vec = name.chars().collect(); let query_chars: Vec = query.chars().collect(); @@ -124,11 +139,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 +232,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; @@ -279,7 +294,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 +383,82 @@ 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 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)" + ); + } +} From dc91a010bfd623bc84f2f98f965e59c8a591522d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 10:59:24 +0000 Subject: [PATCH 061/100] Fix frame node to output Float and re-render on playback The frame node defaulted to Geometry output because with_output_type was never called. Set it to PortType::Float to match the actual NodeOutput. Also wire up the animation bar so advancing a frame triggers a re-render, making the frame node value update during playback. https://claude.ai/code/session_01Loxau3wonmxUoizCkGgZ32 --- crates/nodebox-gui/src/app.rs | 4 +++- crates/nodebox-gui/src/node_library.rs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 2c70bda7..51aa7408 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -670,7 +670,9 @@ impl eframe::App for NodeBoxApp { // Update animation playback if self.animation_bar.is_playing() { - self.animation_bar.update(); + if self.animation_bar.update() { + self.render_pending = true; + } ctx.request_repaint(); } diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index b7e9f606..8149f320 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -698,6 +698,7 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, // Core nodes "frame" => { // No input ports; outputs the current frame number + node = node.with_output_type(PortType::Float); } _ => {} } From 1f1d0ff74af27cbc24bfda11979be50a478784db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 11:01:45 +0000 Subject: [PATCH 062/100] Fix Shift+Tab backward navigation in parameter panel. In egui 0.33, key_pressed(Key::Tab) internally uses count_and_consume_key(Modifiers::NONE, Key::Tab), which only matches Tab without modifiers. When Shift+Tab is pressed the event carries Modifiers::SHIFT, so it was never detected. Use input_mut with explicit count_and_consume_key for both modifier combinations. https://claude.ai/code/session_017xmUgFrKEBrDG2m5NjfQnn --- crates/nodebox-gui/src/parameter_panel.rs | 30 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index af07f085..ade317b4 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -503,14 +503,20 @@ impl ParameterPanel { // Commit on enter or focus lost if output.response.lost_focus() { - let tab_pressed = ui.input(|i| i.key_pressed(egui::Key::Tab)); + // key_pressed only matches Tab without modifiers; + // check Shift+Tab separately for backward navigation. + let (tab_pressed, shift_tab) = ui.input_mut(|i| { + let plain = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Tab) > 0; + let shifted = i.count_and_consume_key(egui::Modifiers::SHIFT, egui::Key::Tab) > 0; + (plain || shifted, shifted) + }); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.editing = None; } else { *value = edit_text; self.editing = None; if tab_pressed { - let forward = !ui.input(|i| i.modifiers.shift); + let forward = !shift_tab; self.tab_target = Self::next_tab_stop(&self.tab_order, &port_key, forward); } } @@ -796,7 +802,13 @@ impl ParameterPanel { // Commit on enter or focus lost if output.response.lost_focus() { - let tab_pressed = ui.input(|i| i.key_pressed(egui::Key::Tab)); + // key_pressed only matches Tab without modifiers; + // check Shift+Tab separately for backward navigation. + let (tab_pressed, shift_tab) = ui.input_mut(|i| { + let plain = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Tab) > 0; + let shifted = i.count_and_consume_key(egui::Modifiers::SHIFT, egui::Key::Tab) > 0; + (plain || shifted, shifted) + }); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.editing = None; } else { @@ -815,7 +827,7 @@ impl ParameterPanel { } self.editing = None; if tab_pressed { - let forward = !ui.input(|i| i.modifiers.shift); + let forward = !shift_tab; self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); } } @@ -925,7 +937,13 @@ impl ParameterPanel { } if output.response.lost_focus() { - let tab_pressed = ui.input(|i| i.key_pressed(egui::Key::Tab)); + // key_pressed only matches Tab without modifiers; + // check Shift+Tab separately for backward navigation. + let (tab_pressed, shift_tab) = ui.input_mut(|i| { + let plain = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Tab) > 0; + let shifted = i.count_and_consume_key(egui::Modifiers::SHIFT, egui::Key::Tab) > 0; + (plain || shifted, shifted) + }); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.editing = None; } else { @@ -941,7 +959,7 @@ impl ParameterPanel { } self.editing = None; if tab_pressed { - let forward = !ui.input(|i| i.modifiers.shift); + let forward = !shift_tab; self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); } } From 0175dad81ae062ca2a39fb3558df0236384a1c29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 11:03:11 +0000 Subject: [PATCH 063/100] Always append numeric index to new node names. Node names now always include an index (e.g. "rect1", "range1") so they can be used as unique identifiers in expressions. https://claude.ai/code/session_01URHKDEpYZKYaQd6otFte8V --- crates/nodebox-core/src/node/node.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/nodebox-core/src/node/node.rs b/crates/nodebox-core/src/node/node.rs index 40edf1e3..92a5fc33 100644 --- a/crates/nodebox-core/src/node/node.rs +++ b/crates/nodebox-core/src/node/node.rs @@ -218,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()) { @@ -378,7 +375,9 @@ mod tests { .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"); } } From 1f5f273fb019f1d9e634205c8bc35091064ead6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 11:25:48 +0000 Subject: [PATCH 064/100] Add alt/option-drag to copy nodes in network view. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding Alt while starting a drag on selected nodes clones them with auto-incremented names (e.g. "range1" → "range2"). Internal connections between copied nodes and incoming connections from external nodes are preserved. Works for single and multiple selected nodes. The parameter panel now tracks the cloned nodes when multi-select changes. https://claude.ai/code/session_01PtBNGLN1NaPt2VGezZ77pe --- crates/nodebox-gui/src/app.rs | 3 + crates/nodebox-gui/src/network_view.rs | 273 +++++++++++++++++++++++++ 2 files changed, 276 insertions(+) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 2c70bda7..a7dc2b38 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -751,6 +751,9 @@ impl eframe::App for NodeBoxApp { 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(); } }); }); diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index 1467b549..9e645547 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -48,6 +48,8 @@ pub struct NetworkView { 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. @@ -103,6 +105,7 @@ impl NetworkView { drag_select_start: Pos2::ZERO, drag_select_current: Pos2::ZERO, selection_before_drag: HashSet::new(), + is_alt_copy_drag: false, } } @@ -111,6 +114,75 @@ impl NetworkView { &self.selected } + /// 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. /// /// The `node_errors` map contains per-node error messages for visual feedback. @@ -460,11 +532,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() @@ -494,6 +576,7 @@ impl NetworkView { } } self.is_dragging_selection = false; + self.is_alt_copy_drag = false; } // Set rendered node (on double-click) @@ -1064,3 +1147,193 @@ 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. +/// +/// Tries `prefix`, then `prefix1`, `prefix2`, etc. +fn generate_unique_name(prefix: &str, existing: &HashSet) -> String { + if !existing.contains(prefix) { + return prefix.to_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), "rect"); + } + + #[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")); + } +} From bd38956335c8563ea35bc93517cb73094822a4e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 12:30:40 +0000 Subject: [PATCH 065/100] Pre-scan input events to reliably detect Shift+Tab direction. The previous approach checked for Tab/Shift+Tab events after TextEdit::show(), but by then egui's focus system (in begin_pass) may have already consumed the Shift+Tab event. Move the detection to before TextEdit::show() by scanning InputState::events directly for Tab key presses and reading the shift modifier from the event itself. Extract a detect_pending_tab() helper used by all three widget handlers (String/Text, Float/Angle, Int). https://claude.ai/code/session_017xmUgFrKEBrDG2m5NjfQnn --- crates/nodebox-gui/src/parameter_panel.rs | 63 +++++++++++++---------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index ade317b4..e1af26a7 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -238,6 +238,30 @@ impl ParameterPanel { String::new() } + /// Scan the current frame's input events for a pending Tab key press. + /// Returns `Some(true)` for forward Tab, `Some(false)` for Shift+Tab (backward), + /// or `None` if no Tab press is pending. + /// + /// Must be called BEFORE `TextEdit::show()` to reliably detect Tab direction, + /// because egui's focus system may consume the event during widget processing. + fn detect_pending_tab(ui: &egui::Ui) -> Option { + ui.input(|i| { + i.events.iter().find_map(|event| { + if let egui::Event::Key { + key: egui::Key::Tab, + pressed: true, + modifiers, + .. + } = event + { + Some(!modifiers.shift) + } else { + None + } + }) + }) + } + /// 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 { @@ -474,6 +498,9 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (value.clone(), true)); + // Pre-scan: detect Tab/Shift+Tab BEFORE TextEdit processes events. + let pending_tab_direction = Self::detect_pending_tab(ui); + let output = egui::TextEdit::singleline(&mut edit_text) .font(TextStyle::Body) .text_color(theme::VALUE_TEXT) @@ -503,20 +530,12 @@ impl ParameterPanel { // Commit on enter or focus lost if output.response.lost_focus() { - // key_pressed only matches Tab without modifiers; - // check Shift+Tab separately for backward navigation. - let (tab_pressed, shift_tab) = ui.input_mut(|i| { - let plain = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Tab) > 0; - let shifted = i.count_and_consume_key(egui::Modifiers::SHIFT, egui::Key::Tab) > 0; - (plain || shifted, shifted) - }); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.editing = None; } else { *value = edit_text; self.editing = None; - if tab_pressed { - let forward = !shift_tab; + if let Some(forward) = pending_tab_direction { self.tab_target = Self::next_tab_stop(&self.tab_order, &port_key, forward); } } @@ -757,6 +776,9 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{:.2}", value), true)); + // Pre-scan: detect Tab/Shift+Tab BEFORE TextEdit processes events. + let pending_tab_direction = Self::detect_pending_tab(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); @@ -802,13 +824,6 @@ impl ParameterPanel { // Commit on enter or focus lost if output.response.lost_focus() { - // key_pressed only matches Tab without modifiers; - // check Shift+Tab separately for backward navigation. - let (tab_pressed, shift_tab) = ui.input_mut(|i| { - let plain = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Tab) > 0; - let shifted = i.count_and_consume_key(egui::Modifiers::SHIFT, egui::Key::Tab) > 0; - (plain || shifted, shifted) - }); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.editing = None; } else { @@ -826,8 +841,7 @@ impl ParameterPanel { } } self.editing = None; - if tab_pressed { - let forward = !shift_tab; + if let Some(forward) = pending_tab_direction { self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); } } @@ -894,6 +908,9 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{}", value), true)); + // Pre-scan: detect Tab/Shift+Tab BEFORE TextEdit processes events. + let pending_tab_direction = Self::detect_pending_tab(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); @@ -937,13 +954,6 @@ impl ParameterPanel { } if output.response.lost_focus() { - // key_pressed only matches Tab without modifiers; - // check Shift+Tab separately for backward navigation. - let (tab_pressed, shift_tab) = ui.input_mut(|i| { - let plain = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Tab) > 0; - let shifted = i.count_and_consume_key(egui::Modifiers::SHIFT, egui::Key::Tab) > 0; - (plain || shifted, shifted) - }); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.editing = None; } else { @@ -958,8 +968,7 @@ impl ParameterPanel { *value = clamped; } self.editing = None; - if tab_pressed { - let forward = !shift_tab; + if let Some(forward) = pending_tab_direction { self.tab_target = Self::next_tab_stop(&self.tab_order, port_key, forward); } } From 6761d1c40fbcd1602c7d14596b96409eae8b88bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 12:34:01 +0000 Subject: [PATCH 066/100] Add word-initial matching tier for node search. Query characters matching first letters of underscore-separated words now score higher (50) than description matches (40) or plain subsequence (20). Typing "rn" ranks random_numbers first, "cr" ranks convert_range first. https://claude.ai/code/session_01RP44EaNqgXdD59PWS4EqCe --- .../nodebox-gui/src/node_selection_dialog.rs | 104 +++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index bb4275db..cd53bd21 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -122,15 +122,31 @@ impl NodeSelectionDialog { return Some(60); } - // Tier 4: Description contains query + // 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 5: Subsequence match on name (fuzzy) + // Tier 6: Subsequence match on name (fuzzy) let name_chars: Vec = name.chars().collect(); - let query_chars: Vec = query.chars().collect(); - if query_chars.len() <= name_chars.len() { let mut qi = 0; for &nc in &name_chars { @@ -439,6 +455,86 @@ mod tests { 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(); From b85874a18c19456c38b9821a4266a7480948f9dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 12:37:12 +0000 Subject: [PATCH 067/100] Simplify animation bar to match Java version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduced controls to just a draggable frame number, play/pause button, and rewind button — matching the original Java AnimationBar. Removed start/end frame limits, FPS control, loop checkbox, step forward/back, and go-to-end buttons. Frame now counts up from 1 with no upper bound. Rewind stops playback and resets to frame 1. All button/drag interactions now trigger re-renders so the frame node output updates immediately. https://claude.ai/code/session_01Loxau3wonmxUoizCkGgZ32 --- crates/nodebox-gui/src/animation_bar.rs | 217 ++++-------------------- crates/nodebox-gui/src/app.rs | 14 +- 2 files changed, 40 insertions(+), 191 deletions(-) diff --git a/crates/nodebox-gui/src/animation_bar.rs b/crates/nodebox-gui/src/animation_bar.rs index b03b7928..ae378040 100644 --- a/crates/nodebox-gui/src/animation_bar.rs +++ b/crates/nodebox-gui/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,105 +139,47 @@ 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 ZINC_700 for subtle elevation against ZINC_800 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::ZINC_700; ui.visuals_mut().widgets.inactive.weak_bg_fill = theme::ZINC_700; ui.visuals_mut().widgets.inactive.bg_stroke = egui::Stroke::NONE; @@ -327,10 +207,8 @@ impl AnimationBar { 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,54 +220,18 @@ 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::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.corner_radius = egui::CornerRadius::ZERO; - - 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.corner_radius = egui::CornerRadius::ZERO; - - 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.corner_radius = egui::CornerRadius::ZERO; - - 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; - - 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::ZINC_700 } else { @@ -398,7 +240,6 @@ impl AnimationBar { 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-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 51aa7408..fe1ba25e 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -6,7 +6,7 @@ use nodebox_port::{Port, ProjectContext}; use std::sync::Arc; use crate::address_bar::{AddressBar, AddressBarAction}; -use crate::animation_bar::AnimationBar; +use crate::animation_bar::{AnimationBar, AnimationEvent}; use crate::components; use crate::history::History; use crate::icon_cache::IconCache; @@ -661,12 +661,20 @@ impl eframe::App for NodeBoxApp { }); // 3. Animation bar (bottom) - frameless, handles its own styling - egui::TopBottomPanel::bottom("animation_bar") + let anim_response = 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); + 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() { From 7acb4586854bb8d60b8244b1393067246e105e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 12:39:46 +0000 Subject: [PATCH 068/100] Align generate_unique_name with always-indexed naming convention. https://claude.ai/code/session_01PtBNGLN1NaPt2VGezZ77pe --- crates/nodebox-gui/src/network_view.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index 9e645547..5dcc2058 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -1162,11 +1162,8 @@ fn extract_name_prefix(name: &str) -> &str { /// Generate a unique name given a prefix and a set of existing names. /// -/// Tries `prefix`, then `prefix1`, `prefix2`, etc. +/// Always appends a numeric index: `prefix1`, `prefix2`, etc. fn generate_unique_name(prefix: &str, existing: &HashSet) -> String { - if !existing.contains(prefix) { - return prefix.to_string(); - } for i in 1..1000 { let name = format!("{}{}", prefix, i); if !existing.contains(&name) { @@ -1196,7 +1193,7 @@ mod tests { #[test] fn test_generate_unique_name_available() { let existing = HashSet::new(); - assert_eq!(generate_unique_name("rect", &existing), "rect"); + assert_eq!(generate_unique_name("rect", &existing), "rect1"); } #[test] From ae7659576b7f12ffbf39bf018965beee86f45fe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 12:44:44 +0000 Subject: [PATCH 069/100] Detect Tab navigation by exclusion instead of key matching. On Linux/X11, Shift+Tab generates ISO_Left_Tab which may not map to egui's Key::Tab, making direct key-event detection unreliable. New approach: when a TextEdit loses focus and it wasn't caused by Escape, Enter, or a mouse click, infer it was Tab/Shift+Tab navigation. Read modifiers.shift to determine direction. This works regardless of how the platform represents the key event. https://claude.ai/code/session_017xmUgFrKEBrDG2m5NjfQnn --- crates/nodebox-gui/src/parameter_panel.rs | 61 ++++++++++++----------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index e1af26a7..b6d72182 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -238,28 +238,17 @@ impl ParameterPanel { String::new() } - /// Scan the current frame's input events for a pending Tab key press. - /// Returns `Some(true)` for forward Tab, `Some(false)` for Shift+Tab (backward), - /// or `None` if no Tab press is pending. + /// Detect whether a TextEdit lost focus due to Tab/Shift+Tab navigation. /// - /// Must be called BEFORE `TextEdit::show()` to reliably detect Tab direction, - /// because egui's focus system may consume the event during widget processing. - fn detect_pending_tab(ui: &egui::Ui) -> Option { - ui.input(|i| { - i.events.iter().find_map(|event| { - if let egui::Event::Key { - key: egui::Key::Tab, - pressed: true, - modifiers, - .. - } = event - { - Some(!modifiers.shift) - } else { - None - } - }) - }) + /// 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. @@ -498,8 +487,8 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (value.clone(), true)); - // Pre-scan: detect Tab/Shift+Tab BEFORE TextEdit processes events. - let pending_tab_direction = Self::detect_pending_tab(ui); + // Capture Enter state before TextEdit may consume it. + let enter_pressed = Self::detect_enter_pressed(ui); let output = egui::TextEdit::singleline(&mut edit_text) .font(TextStyle::Body) @@ -535,7 +524,11 @@ impl ParameterPanel { } else { *value = edit_text; self.editing = None; - if let Some(forward) = pending_tab_direction { + // 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); } } @@ -776,8 +769,8 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{:.2}", value), true)); - // Pre-scan: detect Tab/Shift+Tab BEFORE TextEdit processes events. - let pending_tab_direction = Self::detect_pending_tab(ui); + // 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(); @@ -841,7 +834,11 @@ impl ParameterPanel { } } self.editing = None; - if let Some(forward) = pending_tab_direction { + // 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); } } @@ -908,8 +905,8 @@ impl ParameterPanel { .map(|(_, _, t, sel)| (t.clone(), *sel)) .unwrap_or_else(|| (format!("{}", value), true)); - // Pre-scan: detect Tab/Shift+Tab BEFORE TextEdit processes events. - let pending_tab_direction = Self::detect_pending_tab(ui); + // 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(); @@ -968,7 +965,11 @@ impl ParameterPanel { *value = clamped; } self.editing = None; - if let Some(forward) = pending_tab_direction { + // 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); } } From 66d6e7e2e7da911297cea5116374b1d96deb6f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 13:30:48 +0000 Subject: [PATCH 070/100] Fix resample node crash with negative amount value. Clamp points and length values in eval.rs to prevent capacity overflow when negative values are provided. Add min=1.0 constraint to both ports in the GUI so the parameter panel rejects negative input. https://claude.ai/code/session_01PFA511hyP4JEAhG8TgVrPT --- crates/nodebox-gui/src/eval.rs | 4 ++-- crates/nodebox-gui/src/node_library.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 948aa45c..e6e8b200 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -1091,10 +1091,10 @@ fn execute_node( 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); + let length = get_float(inputs, "length", 10.0).max(1.0); nodebox_ops::resample_by_length(&shape, length) } else { - let points = get_int(inputs, "points", 20) as usize; + let points = get_int(inputs, "points", 20).max(0) as usize; nodebox_ops::resample(&shape, points) }; Ok(NodeOutput::Path(path)) diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 8149f320..46d5a781 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -510,8 +510,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("length", "By length"), MenuItem::new("amount", "By amount"), ])) - .with_input(Port::float("length", 10.0)) - .with_input(Port::int("points", 10)) + .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)); } "wiggle" => { From fbca34e2edcb91ba33bfc48e10db3f4c1e7a9105 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:06:10 +0000 Subject: [PATCH 071/100] Add distribute node for evenly spacing shapes along axes. Port the distribute node from pyvector.py to Rust. The node takes a list of shapes and distributes them evenly along horizontal (left/center/right) and/or vertical (top/middle/bottom) axes, anchoring the two outermost shapes and repositioning everything in between. https://claude.ai/code/session_01RXykJLXjiS6V7MVCstvo1i --- crates/nodebox-gui/src/eval.rs | 11 + crates/nodebox-gui/src/node_library.rs | 23 ++ crates/nodebox-ops/src/filters.rs | 341 ++++++++++++++++++++++++- 3 files changed, 374 insertions(+), 1 deletion(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 948aa45c..dc03b37e 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -1236,6 +1236,17 @@ fn execute_node( 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 = nodebox_ops::HDistribute::from_str(&horizontal); + let v = nodebox_ops::VDistribute::from_str(&vertical); + let paths = nodebox_ops::distribute(&shapes, h, v); + Ok(NodeOutput::Paths(paths)) + } + // Freehand path "corevector.freehand" => { let path_string = get_string(inputs, "path", ""); diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 8149f320..907801e2 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -99,6 +99,12 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "transform", description: "Create multiple copies", }, + NodeTemplate { + name: "distribute", + prototype: "corevector.distribute", + category: "transform", + description: "Distribute shapes on a horizontal or vertical axis", + }, // Color nodes NodeTemplate { name: "colorize", @@ -493,6 +499,23 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::float("rotate", 0.0)) .with_input(Port::point("scale", Point::new(100.0, 100.0))); } + "distribute" => { + node = node + .with_input(Port::geometry("shapes")) + .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); + } "colorize" => { node = node .with_input(Port::geometry("shape")) diff --git a/crates/nodebox-ops/src/filters.rs b/crates/nodebox-ops/src/filters.rs index be4040d1..29e93147 100644 --- a/crates/nodebox-ops/src/filters.rs +++ b/crates/nodebox-ops/src/filters.rs @@ -1,6 +1,6 @@ //! Geometry filters - functions that transform existing shapes. -use nodebox_core::geometry::{Point, Path, Geometry, Color, Transform}; +use nodebox_core::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 @@ -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); + } } From 1036f4b242b1c7664368a141d3e03e4ec13d3b2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:09:31 +0000 Subject: [PATCH 072/100] Expose all implemented nodes in the node selection dialog. 73 nodes were fully implemented in nodebox-ops and handled in eval.rs but missing from NODE_TEMPLATES, making them inaccessible from the UI. This adds all of them: geometry filters (line_angle, connect, align, fit, snap, scatter, etc.), transforms (skew, reflect), math ops (sqrt, sin, cos, round, etc.), string manipulation, list operations, color, data import, and network nodes. https://claude.ai/code/session_01216JxNuaLJvCNcbEYvyuGn --- crates/nodebox-gui/src/node_library.rs | 1236 +++++++++++++++-- .../nodebox-gui/src/node_selection_dialog.rs | 2 +- 2 files changed, 1112 insertions(+), 126 deletions(-) diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 46d5a781..40ae4cf6 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -19,7 +19,9 @@ pub struct NodeTemplate { /// List of all available node templates. pub const NODE_TEMPLATES: &[NodeTemplate] = &[ + // ======================== // Geometry generators + // ======================== NodeTemplate { name: "ellipse", prototype: "corevector.ellipse", @@ -38,6 +40,12 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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", @@ -74,7 +82,144 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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", @@ -99,53 +244,72 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "transform", description: "Create multiple copies", }, + 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", }, - // 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", + name: "rgb_color", + prototype: "color.rgb_color", + category: "color", + description: "Create a color from RGB components", }, - // Modify nodes NodeTemplate { - name: "resample", - prototype: "corevector.resample", - category: "geometry", - description: "Resample path points", + name: "hsb_color", + prototype: "color.hsb_color", + category: "color", + description: "Create a color from HSB components", }, NodeTemplate { - name: "wiggle", - prototype: "corevector.wiggle", - category: "geometry", - description: "Add random displacement to points", + name: "gray_color", + prototype: "color.gray_color", + category: "color", + description: "Create a grayscale color", }, - // Import nodes NodeTemplate { - name: "import_svg", - prototype: "corevector.import_svg", - category: "geometry", - description: "Import an SVG file as geometry", + 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", @@ -170,6 +334,144 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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", @@ -188,12 +490,6 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "math", description: "Generate evenly-spaced samples", }, - NodeTemplate { - name: "compare", - prototype: "math.compare", - category: "math", - description: "Compare two values", - }, NodeTemplate { name: "wave", prototype: "math.wave", @@ -206,7 +502,45 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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", @@ -225,7 +559,111 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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", @@ -238,6 +676,24 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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", @@ -256,32 +712,99 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "list", description: "Take a portion of a list", }, - // Color nodes NodeTemplate { - name: "rgb_color", - prototype: "color.rgb_color", - category: "color", - description: "Create a color from RGB components", + name: "shift", + prototype: "list.shift", + category: "list", + description: "Shift list items by offset", }, NodeTemplate { - name: "hsb_color", - prototype: "color.hsb_color", - category: "color", - description: "Create a color from HSB components", + name: "repeat", + prototype: "list.repeat", + category: "list", + description: "Repeat list items", }, NodeTemplate { - name: "gray_color", - prototype: "color.gray_color", - category: "color", - description: "Create a grayscale color", + 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", + }, + // ======================== // 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 data from a CSV file", + }, + // ======================== + // 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. @@ -317,7 +840,7 @@ impl NodeLibraryBrowser { // Category filter buttons ui.horizontal_wrapped(|ui| { - let categories = ["geometry", "transform", "color", "math", "string", "list", "core"]; + 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() { @@ -397,6 +920,9 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, // Add ports based on node type match template.name { + // ======================== + // Geometry generators + // ======================== "ellipse" => { node = node .with_input(Port::point("position", Point::ZERO)) @@ -416,6 +942,13 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::point("point2", Point::new(100.0, 100.0))) .with_input(Port::int("points", 2).with_min(0.0)); } + "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)); + } "polygon" => { node = node .with_input(Port::point("position", Point::ZERO)) @@ -450,16 +983,194 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::float("t", 50.0)) .with_input(Port::float("distance", 50.0)); } - "grid" => { + "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)); + } + "connect" => { + node = node + .with_input(Port::new("points", PortType::Point).with_port_range(PortRange::List)) + .with_input(Port::boolean("closed", false)); + } + "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", "")); + } + // Combine / structural + "merge" | "group" => { + node = node.with_input(Port::geometry("shapes")); + } + "ungroup" => { + node = node.with_input(Port::geometry("shape")); + } + "null" => { + node = node.with_input(Port::geometry("shape")); + } + // 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)); + } + "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)); + } + "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"), + ])); + } + "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)); + } + "fit_to" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::geometry("bounding")) + .with_input(Port::boolean("keep_proportions", true)); + } + "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)); + } + "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"), + ])); + } + "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)); + } + "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)); + } + "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"), + ])); + } + "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)); + } + // Import + "import_svg" => { 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); + .with_input(Port::string("file", "").with_widget(Widget::File)) + .with_input(Port::boolean("centered", true)) + .with_input(Port::point("position", Point::ZERO)); } + // ======================== + // Transform nodes + // ======================== "translate" => { node = node .with_input(Port::geometry("shape")) @@ -493,6 +1204,22 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::float("rotate", 0.0)) .with_input(Port::point("scale", Point::new(100.0, 100.0))); } + "skew" => { + node = node + .with_input(Port::geometry("shape")) + .with_input(Port::point("skew", Point::ZERO)) + .with_input(Port::point("origin", Point::ZERO)); + } + "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)); + } + // ======================== + // Color nodes + // ======================== "colorize" => { node = node .with_input(Port::geometry("shape")) @@ -500,60 +1227,134 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::color("stroke", Color::BLACK)) .with_input(Port::float("strokeWidth", 1.0)); } - "merge" | "group" => { - node = node.with_input(Port::geometry("shapes")); - } - "resample" => { + "rgb_color" => { 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_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); } - "wiggle" => { + "hsb_color" => { 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_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); } - "textpath" => { + "gray_color" => { 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_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); } - "import_svg" => { + "color" => { 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_input(Port::color("color", Color::BLACK)) + .with_output_type(PortType::Color); } + // ======================== // Math nodes + // ======================== "number" => { node = node.with_input(Port::float("value", 0.0)); } + "integer" => { + node = node.with_input(Port::int("value", 0)); + } + "boolean" => { + node = node.with_input(Port::boolean("value", false)); + } "add" | "subtract" | "multiply" | "divide" => { node = node .with_input(Port::float("value1", 0.0)) .with_input(Port::float("value2", 0.0)); } + "mod" => { + node = node + .with_input(Port::float("value1", 0.0)) + .with_input(Port::float("value2", 1.0)); + } + "negate" | "abs" | "sqrt" => { + node = node.with_input(Port::float("value", 0.0)); + } + "pow" => { + node = node + .with_input(Port::float("value1", 0.0)) + .with_input(Port::float("value2", 2.0)); + } + "log" => { + node = node.with_input(Port::float("value", 1.0)); + } + "ceil" | "floor" => { + node = node.with_input(Port::float("value", 0.0)); + } + "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)); + } + "radians" => { + node = node.with_input(Port::float("degrees", 0.0)); + } + "degrees" => { + node = node.with_input(Port::float("radians", 0.0)); + } + "pi" | "e" => { + node = node.with_output_type(PortType::Float); + } + "even" | "odd" => { + node = node.with_input(Port::float("value", 0.0)); + } + "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"), + ])); + } + "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"), + ])); + } + "angle" | "distance" => { + node = node + .with_input(Port::point("point1", Point::ZERO)) + .with_input(Port::point("point2", Point::new(100.0, 100.0))); + } + "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)) @@ -579,19 +1380,6 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_output_type(PortType::Float) .with_output_range(PortRange::List); } - "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"), - ])); - } "wave" => { node = node .with_input(Port::float("min", 0.0)) @@ -619,7 +1407,26 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("ignore", "Ignore"), ])); } + "sum" | "average" | "max" | "min" => { + node = node + .with_input(Port::new("values", PortType::Float).with_port_range(PortRange::List)); + } + "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", "")); } @@ -637,7 +1444,104 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_output_type(PortType::String) .with_output_range(PortRange::List); } - // List nodes — accept any list type via PortType::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"), + ])); + } + "format_number" => { + node = node + .with_input(Port::float("value", 0.0)) + .with_input(Port::string("format", "%.2f")); + } + "trim" => { + node = node.with_input(Port::string("string", "")); + } + "replace" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("old", "")) + .with_input(Port::string("new", "")); + } + "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)); + } + "character_at" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::int("index", 0)); + } + "as_binary_string" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("digit_separator", "")) + .with_input(Port::string("byte_separator", " ")); + } + "contains" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("contains", "")); + } + "ends_with" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("ends_with", "")); + } + "starts_with" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("starts_with", "")); + } + "equals" => { + node = node + .with_input(Port::string("string", "")) + .with_input(Port::string("equals", "")) + .with_input(Port::boolean("case_sensitive", false)); + } + "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 { @@ -669,37 +1573,119 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, _ => {} } } - // Color nodes - "rgb_color" => { + "second" | "last" => { 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); + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::List); } - "hsb_color" => { + "rest" => { 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); + .with_input(Port::new("list", PortType::List).with_port_range(PortRange::List)) + .with_output_type(PortType::List) + .with_output_range(PortRange::List); } - "gray_color" => { + "shift" => { 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); + .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)); + } + "combine" => { + node = node + .with_input(Port::geometry("list1")) + .with_input(Port::geometry("list2")) + .with_input(Port::geometry("list3")); + } + // ======================== // Core nodes + // ======================== "frame" => { - // No input ports; outputs the current frame number 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_output_type(PortType::String) + .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); + } _ => {} } diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index edfa8b5c..2deb6fd0 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -8,7 +8,7 @@ use crate::node_library::{NodeTemplate, NODE_TEMPLATES, create_node_from_templat use crate::theme; /// Categories for filtering nodes. -const CATEGORIES: &[&str] = &["All", "geometry", "transform", "color", "math", "string", "list", "core"]; +const CATEGORIES: &[&str] = &["All", "geometry", "transform", "color", "math", "string", "list", "core", "data", "network"]; /// The modal node selection dialog. pub struct NodeSelectionDialog { From 8454e051380853eaa4560efddca6af56cc5d0046 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:14:57 +0000 Subject: [PATCH 073/100] Drag output to empty space opens filtered node dialog and auto-connects. When dragging from an output port and releasing on empty canvas space, the node selection dialog now opens filtered to show only nodes with compatible input ports. Selecting a node creates it at the drop position and automatically connects the first compatible input port to the source output. Dismissing the dialog cancels without creating anything. https://claude.ai/code/session_01UkwGZNRZrEAV6a5ospT4tB --- crates/nodebox-gui/src/app.rs | 35 +++++++++++++++++-- crates/nodebox-gui/src/network_view.rs | 32 +++++++++++++++++ crates/nodebox-gui/src/node_library.rs | 10 ++++++ .../nodebox-gui/src/node_selection_dialog.rs | 29 +++++++++++++-- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 75e20f97..e6b206cd 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -14,6 +14,7 @@ use crate::native_menu::{MenuAction, NativeMenuHandle}; 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; @@ -48,6 +49,9 @@ pub struct NodeBoxApp { 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)>, } impl NodeBoxApp { @@ -116,6 +120,7 @@ impl NodeBoxApp { render_pending: true, native_menu, recent_files, + pending_connection: None, } } @@ -196,6 +201,7 @@ impl NodeBoxApp { render_pending: true, native_menu, recent_files, + pending_connection: None, } } @@ -226,6 +232,7 @@ impl NodeBoxApp { render_pending: false, native_menu: None, recent_files: RecentFiles::new(), + pending_connection: None, } } @@ -257,6 +264,7 @@ impl NodeBoxApp { render_pending: false, native_menu: None, recent_files: RecentFiles::new(), + pending_connection: None, } } @@ -752,6 +760,10 @@ impl eframe::App for NodeBoxApp { 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 => {} } @@ -810,12 +822,31 @@ impl eframe::App for NodeBoxApp { 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); - // Select the new node - self.state.selected_node = Some(node_name); + 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") diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index 5dcc2058..c32e3259 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -17,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, + }, } @@ -411,6 +421,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, _)) = @@ -427,11 +438,32 @@ impl NetworkView { node_name, port_name, )); + connection_made = true; } } } } } + + // If no connection was made and cursor is not over any node, open dialog + 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 { + 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; } diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 46d5a781..61dd2729 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -380,6 +380,16 @@ impl NodeLibraryBrowser { } } +/// Check if a node created from this template would have any input port +/// compatible with the given output type. +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 + .iter() + .any(|port| PortType::is_compatible(output_type, &port.port_type)) +} + /// Create a new node from a template. pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, position: Point) -> Node { // Generate unique name diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index edfa8b5c..0f495b48 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -2,9 +2,9 @@ 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. @@ -26,6 +26,8 @@ pub struct NodeSelectionDialog { 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,6 +82,7 @@ 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. @@ -74,6 +92,13 @@ impl NodeSelectionDialog { 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 { From accd80875d7eae3a72082c789bae4a23d455d9bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:18:14 +0000 Subject: [PATCH 074/100] Change string parameter value color from violet to white Match the color of string parameter values in the properties panel with numeric parameter values by using white instead of VIOLET_400. https://claude.ai/code/session_01MuDYnf1Q8C9iDah9cx8W2U --- crates/nodebox-gui/src/parameter_panel.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index 6808916c..8c3517f3 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -517,7 +517,7 @@ impl ParameterPanel { let output = egui::TextEdit::singleline(&mut edit_text) .font(TextStyle::Body) - .text_color(theme::VALUE_TEXT) + .text_color(egui::Color32::WHITE) .desired_width(120.0) .frame(true) .show(ui); @@ -569,7 +569,7 @@ impl ParameterPanel { let galley = ui.painter().layout_no_wrap( display.to_string(), egui::FontId::proportional(11.0), - theme::VALUE_TEXT, + egui::Color32::WHITE, ); let rect = ui.available_rect_before_wrap(); let text_rect = egui::Rect::from_min_size( @@ -578,7 +578,7 @@ impl ParameterPanel { ); let response = ui.allocate_rect(text_rect, Sense::click()); - ui.painter().galley(text_rect.min, galley, theme::VALUE_TEXT); + ui.painter().galley(text_rect.min, galley, egui::Color32::WHITE); if response.clicked() { self.editing = Some((port_key.0, port_key.1, value.clone(), true)); From cd0d73906e54e3e73a271b2e449086fc0555ce25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:23:32 +0000 Subject: [PATCH 075/100] Style string parameter to match number drag controls Replaces the old plain-text string parameter display with the same visual pattern used by numeric drag values: non-interactive TextEdit with frameless layout, hover rounded-rect background (FIELD_HOVER_BG), editing state with manual ZINC_700 background and selection styling. https://claude.ai/code/session_01MuDYnf1Q8C9iDah9cx8W2U --- crates/nodebox-gui/src/parameter_panel.rs | 73 ++++++++++++++++------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index 8c3517f3..c688df95 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -1,7 +1,7 @@ //! UI panels for the NodeBox application. use std::sync::Arc; -use eframe::egui::{self, Sense, TextStyle}; +use eframe::egui::{self, Sense}; use nodebox_core::geometry::Color; use nodebox_core::node::{PortType, Widget}; use nodebox_core::Value; @@ -507,7 +507,7 @@ impl ParameterPanel { Widget::String | Widget::Text => { if let Value::String(ref mut value) = port.value { if is_editing { - // Show text input + // 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)); @@ -515,19 +515,35 @@ impl ParameterPanel { // 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(TextStyle::Body) + .font(egui::FontId::proportional(theme::FONT_SIZE_SMALL)) .text_color(egui::Color32::WHITE) - .desired_width(120.0) - .frame(true) + .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; } - // 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( @@ -564,28 +580,41 @@ impl ParameterPanel { 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), - egui::Color32::WHITE, - ); - 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(), - ); + // 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); - let response = ui.allocate_rect(text_rect, Sense::click()); - ui.painter().galley(text_rect.min, galley, egui::Color32::WHITE); + // 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()); - if response.clicked() { - self.editing = Some((port_key.0, port_key.1, value.clone(), true)); + // 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)); + } } } } From 8527d8043d43c2f3ef4b1bf55510e5a8ca6767da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:33:16 +0000 Subject: [PATCH 076/100] Fix dialog filtering to check only first port with strict rules; skip reroute drags. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. template_has_compatible_input now only checks the first input port using strict type rules (same type, List wildcard, Int↔Float). This excludes the broad everything→String and Number→Point conversions, so e.g. Geometry output no longer shows textpath or arc. 2. ConnectionDrag gains an is_reroute flag. Disconnect-and-reroute drags (initiated from input ports) set this to true, preventing the node selection dialog from opening when released on empty space. https://claude.ai/code/session_01UkwGZNRZrEAV6a5ospT4tB --- crates/nodebox-gui/src/network_view.rs | 26 +++++++++++++------- crates/nodebox-gui/src/node_library.rs | 34 +++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index c32e3259..4c8e540c 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -70,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). @@ -340,6 +343,7 @@ impl NetworkView { from_node: child.name.clone(), output_type: child.output_type.clone(), to_pos: output_pos, + is_reroute: false, }); } @@ -446,21 +450,24 @@ impl NetworkView { } // 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 { - 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(), - }; + 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(), + }; + } } } } @@ -486,6 +493,7 @@ impl NetworkView { from_node: from_node_name, output_type, to_pos: output_pos, + is_reroute: true, }); } } diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 61dd2729..7b9e5cb9 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -380,14 +380,40 @@ impl NodeLibraryBrowser { } } -/// Check if a node created from this template would have any input port -/// compatible with the given output type. +/// 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 - .iter() - .any(|port| PortType::is_compatible(output_type, &port.port_type)) + .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. From 50017bda74997436cd76e5af5e2b82105bf5002c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 19:35:13 +0000 Subject: [PATCH 077/100] Implement data nodes: import_csv, make_table, lookup, filter_data. Add DataValue type and DataRow/DataRows NodeOutput variants to support structured tabular data. The csv crate handles robust CSV parsing with configurable delimiters, quote characters, and number separators. Auto-detects numeric columns. Includes 19 unit tests. https://claude.ai/code/session_01Ssbet1itX47TACaYWBM1Wo --- Cargo.lock | 28 ++ crates/nodebox-core/src/node/port.rs | 12 + crates/nodebox-gui/src/eval.rs | 149 +++++- crates/nodebox-gui/src/node_library.rs | 95 +++- .../nodebox-gui/src/node_selection_dialog.rs | 2 +- crates/nodebox-ops/Cargo.toml | 1 + crates/nodebox-ops/src/data.rs | 464 ++++++++++++++++++ crates/nodebox-ops/src/lib.rs | 1 + 8 files changed, 728 insertions(+), 24 deletions(-) create mode 100644 crates/nodebox-ops/src/data.rs diff --git a/Cargo.lock b/Cargo.lock index c0876c33..791a1ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1092,6 +1092,27 @@ 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" @@ -3003,6 +3024,7 @@ name = "nodebox-ops" version = "0.1.0" dependencies = [ "approx", + "csv", "nodebox-core", "proptest", "rayon", @@ -4382,6 +4404,12 @@ dependencies = [ "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" diff --git a/crates/nodebox-core/src/node/port.rs b/crates/nodebox-core/src/node/port.rs index 53e1fbae..8ad084f0 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, } @@ -89,6 +93,14 @@ impl PortType { return true; } + // Data <-> List bidirectional (data nodes consume and produce lists of data rows) + if matches!(output_type, PortType::Data) && matches!(input_type, PortType::List) { + return true; + } + if matches!(output_type, PortType::List) && matches!(input_type, PortType::Data) { + return true; + } + // Everything can be converted to a string if matches!(input_type, PortType::String) { return true; diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 948aa45c..6997ed71 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -8,6 +8,7 @@ use nodebox_core::node::{Node, NodeLibrary, EvalError}; use nodebox_core::node::PortRange; use nodebox_core::Value; use nodebox_port::{Port, ProjectContext}; +use nodebox_ops::data::DataValue; use crate::render_worker::CancellationToken; /// Error information for a specific node. @@ -77,6 +78,10 @@ pub enum NodeOutput { 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 { @@ -156,6 +161,22 @@ impl NodeOutput { 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() + } } } @@ -170,6 +191,7 @@ impl NodeOutput { NodeOutput::Color(_) | NodeOutput::Colors(_) => "color", NodeOutput::Point(_) | NodeOutput::Points(_) => "point", NodeOutput::Path(_) | NodeOutput::Paths(_) => "path", + NodeOutput::DataRow(_) | NodeOutput::DataRows(_) => "data", } } @@ -184,6 +206,7 @@ impl NodeOutput { NodeOutput::Strings(ss) => ss.len(), NodeOutput::Booleans(bs) => bs.len(), NodeOutput::Colors(cs) => cs.len(), + NodeOutput::DataRows(rs) => rs.len(), _ => 1, } } @@ -215,6 +238,7 @@ impl NodeOutput { 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 } } @@ -229,6 +253,7 @@ impl NodeOutput { NodeOutput::Strings(ss) => ss.len(), NodeOutput::Booleans(bs) => bs.len(), NodeOutput::Colors(cs) => cs.len(), + NodeOutput::DataRows(rs) => rs.len(), NodeOutput::None => 0, _ => 1, } @@ -526,6 +551,13 @@ fn collect_results(results: Vec) -> NodeOutput { }).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() @@ -800,7 +832,17 @@ fn value_to_output(value: &Value) -> NodeOutput { 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 + 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) + } } } @@ -895,6 +937,32 @@ fn get_booleans(inputs: &HashMap, name: &str) -> Vec { } } +/// 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) { @@ -2054,38 +2122,75 @@ fn execute_node( "data.import_csv" => { let file_path = get_string(inputs, "file", ""); if file_path.is_empty() { - return Ok(NodeOutput::Strings(Vec::new())); + 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" => ';', - "colon" => ':', - "tab" => '\t', - "space" => ' ', - _ => ',', + "semicolon" => b';', + "colon" => b':', + "tab" => b'\t', + "space" => b' ', + _ => b',', }; - // Simple CSV parsing: split by delimiter, return as list of strings - let lines: Vec = content.lines() - .map(|line| { - line.split(delimiter) - .map(|field| field.trim().to_string()) - .collect::>() - .join("\t") - }) - .collect(); - Ok(NodeOutput::Strings(lines)) + 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 = nodebox_ops::data::import_csv( + &content, delimiter, quote_char, &number_separator, + ); + Ok(NodeOutput::DataRows(rows)) } Err(e) => { log::warn!("Import CSV error: {}", e); - Ok(NodeOutput::Strings(Vec::new())) + Ok(NodeOutput::DataRows(Vec::new())) } } } - "data.lookup" | "data.filter_data" | "data.make_table" => { - // These require Map/table support - log::warn!("Data node not yet fully supported: {}", proto); - Ok(NodeOutput::None) + "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 = nodebox_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 nodebox_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 nodebox_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 = nodebox_ops::data::filter_data(&rows, &key, &op, &value); + Ok(NodeOutput::DataRows(filtered)) } "network.query_json" => { diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 8149f320..4655203f 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -275,6 +275,37 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ category: "color", description: "Create a grayscale color", }, + // Data nodes + NodeTemplate { + name: "import_text", + prototype: "data.import_text", + category: "data", + description: "Import a text file and return each line", + }, + 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", + }, // Core nodes NodeTemplate { name: "frame", @@ -317,7 +348,7 @@ impl NodeLibraryBrowser { // Category filter buttons ui.horizontal_wrapped(|ui| { - let categories = ["geometry", "transform", "color", "math", "string", "list", "core"]; + let categories = ["geometry", "transform", "color", "math", "string", "list", "data", "core"]; for cat in categories { let is_selected = self.selected_category.as_deref() == Some(cat); if ui.selectable_label(is_selected, cat).clicked() { @@ -695,6 +726,68 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_input(Port::float("range", 255.0)) .with_output_type(PortType::Color); } + // 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", ","), + MenuItem::new("semicolon", ";"), + MenuItem::new("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::String); + } + "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); + } // Core nodes "frame" => { // No input ports; outputs the current frame number diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-gui/src/node_selection_dialog.rs index edfa8b5c..f18aa552 100644 --- a/crates/nodebox-gui/src/node_selection_dialog.rs +++ b/crates/nodebox-gui/src/node_selection_dialog.rs @@ -8,7 +8,7 @@ use crate::node_library::{NodeTemplate, NODE_TEMPLATES, create_node_from_templat use crate::theme; /// Categories for filtering nodes. -const CATEGORIES: &[&str] = &["All", "geometry", "transform", "color", "math", "string", "list", "core"]; +const CATEGORIES: &[&str] = &["All", "geometry", "transform", "color", "math", "string", "list", "data", "core"]; /// The modal node selection dialog. pub struct NodeSelectionDialog { diff --git a/crates/nodebox-ops/Cargo.toml b/crates/nodebox-ops/Cargo.toml index 0706e787..1a5647e9 100644 --- a/crates/nodebox-ops/Cargo.toml +++ b/crates/nodebox-ops/Cargo.toml @@ -11,6 +11,7 @@ authors.workspace = true nodebox-core = { path = "../nodebox-core" } rayon = "1.10" usvg = "0.42" +csv = "1" [features] default = [] diff --git a/crates/nodebox-ops/src/data.rs b/crates/nodebox-ops/src/data.rs new file mode 100644 index 00000000..840a5421 --- /dev/null +++ b/crates/nodebox-ops/src/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/lib.rs b/crates/nodebox-ops/src/lib.rs index 1229c237..3774fce2 100644 --- a/crates/nodebox-ops/src/lib.rs +++ b/crates/nodebox-ops/src/lib.rs @@ -18,6 +18,7 @@ pub mod filters; pub mod math; pub mod list; pub mod string; +pub mod data; pub mod parallel; pub mod svg; From 33f676e12e1d3401e84fa820532e9a07c0addb16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 19:44:38 +0000 Subject: [PATCH 078/100] Load old .ndbx format versions best-effort with a warning banner. Instead of rejecting .ndbx files with format versions older than 21, the app now loads them best-effort and shows an unobtrusive zinc-colored notification banner below the address bar. The banner is dismissible and warns the user that some features may not work correctly. https://claude.ai/code/session_01JG1Wm6BwvGAZ7MPVi9PeLY --- crates/nodebox-gui/src/app.rs | 22 ++++ crates/nodebox-gui/src/lib.rs | 3 +- crates/nodebox-gui/src/notification_banner.rs | 111 ++++++++++++++++++ crates/nodebox-gui/src/state.rs | 54 ++++++++- crates/nodebox-gui/tests/file_tests.rs | 37 +++--- crates/nodebox-ndbx/src/lib.rs | 4 +- crates/nodebox-ndbx/src/parser.rs | 16 ++- crates/nodebox-ndbx/src/upgrades.rs | 59 +++++++--- crates/nodebox-ndbx/tests/parse_examples.rs | 37 +++--- 9 files changed, 286 insertions(+), 57 deletions(-) create mode 100644 crates/nodebox-gui/src/notification_banner.rs diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 75e20f97..1431593c 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -11,6 +11,7 @@ use crate::components; use crate::history::History; 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; @@ -660,6 +661,27 @@ impl eframe::App for NodeBoxApp { } }); + // 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) diff --git a/crates/nodebox-gui/src/lib.rs b/crates/nodebox-gui/src/lib.rs index aa929aa9..04149795 100644 --- a/crates/nodebox-gui/src/lib.rs +++ b/crates/nodebox-gui/src/lib.rs @@ -32,6 +32,7 @@ mod icon_cache; mod network_view; mod node_library; mod node_selection_dialog; +mod notification_banner; mod pan_zoom; mod parameter_panel; pub mod render_worker; @@ -51,7 +52,7 @@ 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 state::{populate_default_ports, AppState, Notification, NotificationLevel}; // Re-export commonly used types from dependencies pub use nodebox_core::geometry::{Color, Path, Point}; diff --git a/crates/nodebox-gui/src/notification_banner.rs b/crates/nodebox-gui/src/notification_banner.rs new file mode 100644 index 00000000..99c30518 --- /dev/null +++ b/crates/nodebox-gui/src/notification_banner.rs @@ -0,0 +1,111 @@ +//! 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 + let icon_font = egui::FontId::proportional(12.0); + let icon_galley = ui.painter().layout_no_wrap( + "\u{26A0}".to_string(), + icon_font, + icon_color, + ); + let icon_x = rect.left() + theme::PADDING; + ui.painter().galley( + egui::pos2(icon_x, rect.center().y - icon_galley.size().y / 2.0), + icon_galley.clone(), + icon_color, + ); + + // Message text + let text_font = egui::FontId::proportional(11.0); + let text_x = icon_x + icon_galley.size().x + theme::PADDING_SMALL; + 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 + }; + + ui.painter().text( + dismiss_rect.center(), + egui::Align2::CENTER_CENTER, + "\u{2715}", + egui::FontId::proportional(12.0), + dismiss_color, + ); + + if dismiss_response.clicked() { + dismissed.push(*id); + } + } + + dismissed +} diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-gui/src/state.rs index d757a1af..bc47665f 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-gui/src/state.rs @@ -7,6 +7,27 @@ 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). @@ -37,6 +58,12 @@ pub struct AppState { /// 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 { @@ -63,6 +90,8 @@ impl AppState { library, node_errors: HashMap::new(), node_output: NodeOutput::None, + notifications: Vec::new(), + notification_counter: 0, } } @@ -95,6 +124,7 @@ impl AppState { self.node_output = NodeOutput::None; self.selected_node = None; self.node_errors.clear(); + self.notifications.clear(); } /// Load a file. @@ -102,8 +132,9 @@ impl AppState { /// 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 - let mut library = nodebox_ndbx::parse_file(path).map_err(|e| e.to_string())?; + // Parse the .ndbx file with warnings (old format versions load best-effort) + let (mut library, warnings) = + nodebox_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); @@ -118,9 +149,28 @@ impl AppState { 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_ndbx::serialize_to_file(&self.library, path) diff --git a/crates/nodebox-gui/tests/file_tests.rs b/crates/nodebox-gui/tests/file_tests.rs index 96a15c2a..0047b28a 100644 --- a/crates/nodebox-gui/tests/file_tests.rs +++ b/crates/nodebox-gui/tests/file_tests.rs @@ -1,9 +1,8 @@ //! Tests for loading and evaluating .ndbx files. //! -//! Note: The Rust implementation only supports .ndbx format version 21+. -//! Older example files (version 17) from the Java implementation are rejected. -//! These tests verify that behavior and test evaluation with programmatically -//! created libraries. +//! 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; @@ -12,7 +11,6 @@ use nodebox_core::geometry::{Color, Point}; use nodebox_core::node::{Connection, Node, NodeLibrary, Port}; use nodebox_gui::eval::evaluate_network; use nodebox_gui::{populate_default_ports, AppState}; -use nodebox_ndbx::NdbxError; use nodebox_port::{Port as PortTrait, ProjectContext, TestPort}; /// Create a test port and project context for evaluation tests. @@ -47,23 +45,22 @@ fn libraries_dir() -> PathBuf { // ============================================================================ #[test] -fn test_old_version_files_are_rejected() { +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_ndbx::parse_file(&path); - assert!(result.is_err(), "Old version files should be rejected"); + let result = nodebox_ndbx::parse_file_with_warnings(&path); + assert!(result.is_ok(), "Old version files should load best-effort"); - match result.unwrap_err() { - NdbxError::UnsupportedVersion(v) => { - assert_eq!(v, 17, "Expected version 17 rejection"); - } - other => panic!("Expected UnsupportedVersion error, got: {:?}", other), - } + 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] @@ -338,10 +335,10 @@ fn test_app_state_new() { } #[test] -fn test_app_state_load_file_old_version() { +fn test_app_state_load_file_old_version_warns() { let mut state = AppState::new(); - // Try to load an old version file - should fail + // 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"); @@ -350,8 +347,12 @@ fn test_app_state_load_file_old_version() { let result = state.load_file(&path); assert!( - result.is_err(), - "load_file should fail for old version files" + 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" ); } diff --git a/crates/nodebox-ndbx/src/lib.rs b/crates/nodebox-ndbx/src/lib.rs index 0fdb41f7..0db1c5f3 100644 --- a/crates/nodebox-ndbx/src/lib.rs +++ b/crates/nodebox-ndbx/src/lib.rs @@ -21,6 +21,6 @@ mod serializer; mod upgrades; pub use error::{NdbxError, Result}; -pub use parser::{parse, parse_file}; +pub use parser::{parse, parse_file, parse_file_with_warnings}; pub use serializer::{serialize, serialize_to_file}; -pub use upgrades::{upgrade, CURRENT_FORMAT_VERSION, MIN_SUPPORTED_VERSION}; +pub use upgrades::{upgrade, UpgradeResult, CURRENT_FORMAT_VERSION, MIN_SUPPORTED_VERSION}; diff --git a/crates/nodebox-ndbx/src/parser.rs b/crates/nodebox-ndbx/src/parser.rs index b54b08f1..40d2e924 100644 --- a/crates/nodebox-ndbx/src/parser.rs +++ b/crates/nodebox-ndbx/src/parser.rs @@ -13,13 +13,23 @@ use nodebox_core::Value; use crate::error::{NdbxError, Result}; use crate::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)?; - let mut library = parse(&content)?; - upgrade(&mut library)?; + let (library, _warnings) = parse_file_with_warnings(path)?; Ok(library) } diff --git a/crates/nodebox-ndbx/src/upgrades.rs b/crates/nodebox-ndbx/src/upgrades.rs index 9feed241..400c24e6 100644 --- a/crates/nodebox-ndbx/src/upgrades.rs +++ b/crates/nodebox-ndbx/src/upgrades.rs @@ -1,7 +1,7 @@ //! Version upgrades for .ndbx files. //! //! This module handles upgrading older .ndbx file formats to the current version. -//! Currently supports upgrading from Java's version 21 to Rust's version 22. +//! Old versions (< 21) are loaded best-effort with a warning. use nodebox_core::node::NodeLibrary; @@ -10,24 +10,41 @@ use crate::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 (Java's latest). +/// 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. /// -/// # Errors -/// -/// Returns an error if the format version is too old (< 21) or too new (> 22). -pub fn upgrade(library: &mut NodeLibrary) -> Result<(), NdbxError> { +/// 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 => Err(NdbxError::UnsupportedVersion(v)), + 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(()) + Ok(result) } - 22 => Ok(()), // Already current + 22 => Ok(result), // Already current v => Err(NdbxError::UnsupportedVersion(v)), } } @@ -41,8 +58,9 @@ mod tests { let mut library = NodeLibrary::default(); library.format_version = 21; - upgrade(&mut library).unwrap(); + let result = upgrade(&mut library).unwrap(); assert_eq!(library.format_version, 22); + assert!(result.warnings.is_empty()); } #[test] @@ -50,17 +68,30 @@ mod tests { let mut library = NodeLibrary::default(); library.format_version = 22; - upgrade(&mut library).unwrap(); + let result = upgrade(&mut library).unwrap(); assert_eq!(library.format_version, 22); + assert!(result.warnings.is_empty()); } #[test] - fn test_upgrade_old_version_error() { + fn test_upgrade_old_version_warns() { let mut library = NodeLibrary::default(); library.format_version = 20; - let result = upgrade(&mut library); - assert!(result.is_err()); + 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] diff --git a/crates/nodebox-ndbx/tests/parse_examples.rs b/crates/nodebox-ndbx/tests/parse_examples.rs index ecaff5d3..4e30f594 100644 --- a/crates/nodebox-ndbx/tests/parse_examples.rs +++ b/crates/nodebox-ndbx/tests/parse_examples.rs @@ -1,11 +1,11 @@ //! Integration tests for parsing actual NDBX files. -use nodebox_ndbx::{parse_file, NdbxError, MIN_SUPPORTED_VERSION}; +use nodebox_ndbx::{parse_file, parse_file_with_warnings}; use std::path::Path; -/// Test that parsing old format versions (< 21) returns an error. +/// Test that parsing old format versions (< 21) succeeds best-effort with warnings. #[test] -fn test_parse_old_version_returns_error() { +fn test_parse_old_version_loads_with_warning() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../examples/01 Basics/01 Shape/01 Primitives/01 Primitives.ndbx"); @@ -15,15 +15,16 @@ fn test_parse_old_version_returns_error() { } // This file has formatVersion="17" which is below our minimum supported version - let result = parse_file(&path); - assert!(result.is_err(), "Expected error for old format version"); - - match result.unwrap_err() { - NdbxError::UnsupportedVersion(v) => { - assert!(v < MIN_SUPPORTED_VERSION, "Expected version < 21, got {}", v); - } - other => panic!("Expected UnsupportedVersion error, got: {:?}", other), - } + // but should load best-effort with a warning + let (library, warnings) = parse_file_with_warnings(&path) + .expect("Old version files should load best-effort"); + assert!(!warnings.is_empty(), "Expected warning for 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"); + + // parse_file (without warnings) should also succeed + let library = parse_file(&path).expect("parse_file should also succeed for old versions"); + assert_eq!(library.format_version, 22); } /// Test parsing the corevector library. @@ -61,9 +62,9 @@ fn test_parse_corevector_library() { assert!(rect_port_names.contains(&"height"), "rect missing height port"); } -/// Test that parsing a very old demo file (version 0) returns an error. +/// Test that parsing a very old demo file (version 0) succeeds best-effort with warnings. #[test] -fn test_parse_old_demo_file_returns_error() { +fn test_parse_old_demo_file_loads_with_warning() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../src/test/files/demo.ndbx"); @@ -72,7 +73,9 @@ fn test_parse_old_demo_file_returns_error() { return; } - // This file has an old/missing format version - let result = parse_file(&path); - assert!(result.is_err(), "Expected error for old format version"); + // 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"); + // May or may not have warnings depending on the file's version } From 5209344b10257e948e8ff444b0a2aa25cc9a7edf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 20:07:13 +0000 Subject: [PATCH 079/100] Set explicit output types for all node templates Math, string, and list nodes were defaulting to PortType::Geometry because Node::default() uses Geometry and their template arms didn't call .with_output_type(). This caused incorrect type checking, port colors, and tooltips in the UI. - Add .with_output_type(PortType::Float) to math nodes (add, subtract, multiply, divide, mod, negate, abs, sqrt, pow, log, ceil, floor, sin, cos, radians, degrees, number, angle, distance, wave, convert_range, sum, average, max, min) - Add .with_output_type(PortType::Boolean) to boolean/compare nodes (boolean, even, odd, compare, logical, contains, ends_with, starts_with, equals) - Add .with_output_type(PortType::Int) to integer node - Add .with_output_type(PortType::String) to string nodes (string, concatenate, change_case, format_number, trim, replace, sub_string, character_at, as_binary_string) - Add .with_output_type(PortType::List) to switch node - Make all geometry/transform nodes explicitly set PortType::Geometry instead of relying on the default - Add debug_assert to catch future omissions when adding new node templates https://claude.ai/code/session_01BKEcarNXC7wPBQ5Bt3X4ZM --- crates/nodebox-gui/src/node_library.rs | 233 +++++++++++++++++-------- 1 file changed, 164 insertions(+), 69 deletions(-) diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 34f51243..a8468af0 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -954,7 +954,9 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_category(template.category) .with_position(position.x, position.y); - // Add ports based on node type + // 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 @@ -963,41 +965,47 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, 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("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_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_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_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_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_input(Port::float("inner", 100.0)) + .with_output_type(PortType::Geometry); } "arc" => { node = node @@ -1010,14 +1018,16 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, 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_input(Port::float("distance", 50.0)) + .with_output_type(PortType::Geometry); } "grid" => { node = node @@ -1041,12 +1051,14 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("JUSTIFY", "Justify"), ])) .with_input(Port::point("position", Point::ZERO)) - .with_input(Port::float("width", 0.0)); + .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_input(Port::boolean("closed", false)) + .with_output_type(PortType::Geometry); } "make_point" => { node = node @@ -1055,17 +1067,25 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_output_type(PortType::Point); } "freehand" => { - node = node.with_input(Port::string("path", "")); + node = node + .with_input(Port::string("path", "")) + .with_output_type(PortType::Geometry); } // Combine / structural "merge" | "group" => { - node = node.with_input(Port::geometry("shapes")); + node = node + .with_input(Port::geometry("shapes")) + .with_output_type(PortType::Geometry); } "ungroup" => { - node = node.with_input(Port::geometry("shape")); + node = node + .with_input(Port::geometry("shape")) + .with_output_type(PortType::Geometry); } "null" => { - node = node.with_input(Port::geometry("shape")); + node = node + .with_input(Port::geometry("shape")) + .with_output_type(PortType::Geometry); } // Modify / filter geometry "resample" => { @@ -1077,7 +1097,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, ])) .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_input(Port::boolean("per_contour", false)) + .with_output_type(PortType::Geometry); } "wiggle" => { node = node @@ -1088,7 +1109,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("paths", "Paths"), ])) .with_input(Port::point("offset", Point::new(10.0, 10.0))) - .with_input(Port::int("seed", 0)); + .with_input(Port::int("seed", 0)) + .with_output_type(PortType::Geometry); } "align" => { node = node @@ -1103,7 +1125,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("top", "Top"), MenuItem::new("middle", "Middle"), MenuItem::new("bottom", "Bottom"), - ])); + ])) + .with_output_type(PortType::Geometry); } "fit" => { node = node @@ -1111,20 +1134,23 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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_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_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_input(Port::point("position", Point::ZERO)) + .with_output_type(PortType::Geometry); } "centroid" => { node = node @@ -1156,7 +1182,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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 @@ -1167,7 +1194,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("distance", "Distance"), MenuItem::new("angle", "Angle"), ])) - .with_input(Port::point("position", Point::ZERO)); + .with_input(Port::point("position", Point::ZERO)) + .with_output_type(PortType::Geometry); } "stack" => { node = node @@ -1178,7 +1206,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("north", "North"), MenuItem::new("south", "South"), ])) - .with_input(Port::float("margin", 0.0)); + .with_input(Port::float("margin", 0.0)) + .with_output_type(PortType::Geometry); } "link" => { node = node @@ -1187,7 +1216,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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 @@ -1195,14 +1225,16 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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_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_input(Port::point("position", Point::ZERO)) + .with_output_type(PortType::Geometry); } // ======================== // Transform nodes @@ -1210,19 +1242,22 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, "translate" => { node = node .with_input(Port::geometry("shape")) - .with_input(Port::point("translate", Point::ZERO)); + .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_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_input(Port::point("origin", Point::ZERO)) + .with_output_type(PortType::Geometry); } "copy" => { node = node @@ -1238,20 +1273,23 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, ])) .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_input(Port::point("scale", Point::new(100.0, 100.0))) + .with_output_type(PortType::Geometry); } "skew" => { node = node .with_input(Port::geometry("shape")) .with_input(Port::point("skew", Point::ZERO)) - .with_input(Port::point("origin", 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_input(Port::boolean("keep_original", true)) + .with_output_type(PortType::Geometry); } // ======================== // Color nodes @@ -1261,7 +1299,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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_input(Port::float("strokeWidth", 1.0)) + .with_output_type(PortType::Geometry); } "rgb_color" => { node = node @@ -1297,37 +1336,52 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, // Math nodes // ======================== "number" => { - node = node.with_input(Port::float("value", 0.0)); + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Float); } "integer" => { - node = node.with_input(Port::int("value", 0)); + node = node + .with_input(Port::int("value", 0)) + .with_output_type(PortType::Int); } "boolean" => { - node = node.with_input(Port::boolean("value", false)); + 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_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_input(Port::float("value2", 1.0)) + .with_output_type(PortType::Float); } "negate" | "abs" | "sqrt" => { - node = node.with_input(Port::float("value", 0.0)); + 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_input(Port::float("value2", 2.0)) + .with_output_type(PortType::Float); } "log" => { - node = node.with_input(Port::float("value", 1.0)); + 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)); + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Float); } "round" => { node = node @@ -1335,19 +1389,27 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .with_output_type(PortType::Int); } "sin" | "cos" => { - node = node.with_input(Port::float("value", 0.0)); + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Float); } "radians" => { - node = node.with_input(Port::float("degrees", 0.0)); + node = node + .with_input(Port::float("degrees", 0.0)) + .with_output_type(PortType::Float); } "degrees" => { - node = node.with_input(Port::float("radians", 0.0)); + 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)); + node = node + .with_input(Port::float("value", 0.0)) + .with_output_type(PortType::Boolean); } "compare" => { node = node @@ -1360,7 +1422,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new(">=", "Greater or Equal"), MenuItem::new("==", "Equal"), MenuItem::new("!=", "Not Equal"), - ])); + ])) + .with_output_type(PortType::Boolean); } "logical" => { node = node @@ -1369,12 +1432,14 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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_input(Port::point("point2", Point::new(100.0, 100.0))) + .with_output_type(PortType::Float); } "coordinates" => { node = node @@ -1427,7 +1492,8 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, MenuItem::new("square", "Square"), MenuItem::new("triangle", "Triangle"), MenuItem::new("sawtooth", "Sawtooth"), - ])); + ])) + .with_output_type(PortType::Float); } "convert_range" => { node = node @@ -1441,11 +1507,13 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, 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_input(Port::new("values", PortType::Float).with_port_range(PortRange::List)) + .with_output_type(PortType::Float); } "make_numbers" => { node = node @@ -1464,14 +1532,17 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, // String nodes // ======================== "string" => { - node = node.with_input(Port::string("value", "")); + 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_input(Port::string("string4", "")) + .with_output_type(PortType::String); } "make_strings" => { node = node @@ -1492,60 +1563,72 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, 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_input(Port::string("format", "%.2f")) + .with_output_type(PortType::String); } "trim" => { - node = node.with_input(Port::string("string", "")); + 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_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_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_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_input(Port::string("byte_separator", " ")) + .with_output_type(PortType::String); } "contains" => { node = node .with_input(Port::string("string", "")) - .with_input(Port::string("contains", "")); + .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_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_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_input(Port::boolean("case_sensitive", false)) + .with_output_type(PortType::Boolean); } "characters" => { node = node @@ -1673,13 +1756,16 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, 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_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_input(Port::geometry("list3")) + .with_output_type(PortType::Geometry); } // ======================== // Core nodes @@ -1722,8 +1808,17 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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 } From 907df6554cdc2750aca570b0c9b45b41accedbbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 20:09:41 +0000 Subject: [PATCH 080/100] Draw dismiss X and warning triangle with lines instead of Unicode. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unicode characters like ✕ and ⚠ may not have glyphs in the default font, causing them to render as squares. Drawing with painter lines ensures consistent rendering across all platforms. https://claude.ai/code/session_01JG1Wm6BwvGAZ7MPVi9PeLY --- crates/nodebox-gui/src/notification_banner.rs | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/crates/nodebox-gui/src/notification_banner.rs b/crates/nodebox-gui/src/notification_banner.rs index 99c30518..c0c58f4e 100644 --- a/crates/nodebox-gui/src/notification_banner.rs +++ b/crates/nodebox-gui/src/notification_banner.rs @@ -44,23 +44,29 @@ pub fn show_notifications( egui::Stroke::new(1.0, theme::ZINC_600), ); - // Warning icon - let icon_font = egui::FontId::proportional(12.0); - let icon_galley = ui.painter().layout_no_wrap( - "\u{26A0}".to_string(), - icon_font, - icon_color, - ); + // Warning icon: draw a small triangle with "!" using lines let icon_x = rect.left() + theme::PADDING; - ui.painter().galley( - egui::pos2(icon_x, rect.center().y - icon_galley.size().y / 2.0), - icon_galley.clone(), - icon_color, + 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_galley.size().x + theme::PADDING_SMALL; + 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( @@ -94,12 +100,17 @@ pub fn show_notifications( theme::ZINC_400 }; - ui.painter().text( - dismiss_rect.center(), - egui::Align2::CENTER_CENTER, - "\u{2715}", - egui::FontId::proportional(12.0), - dismiss_color, + // 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() { From a83d52e591c89edd6b461d724dc8e17f5bcb910c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 20:27:02 +0000 Subject: [PATCH 081/100] Fix deprecated egui menu API warnings. Replace egui::menu::bar() with egui::MenuBar::new().ui() and ui.close_menu() with ui.close() to resolve all 16 deprecation warnings. Add compiler warnings policy to AGENTS.md. https://claude.ai/code/session_01SyjWrSfg4ksquQVcf4grFk --- AGENTS.md | 3 +++ crates/nodebox-gui/src/app.rs | 32 ++++++++++++++++---------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e407eee..ef3dce15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,9 @@ Prereqs: Java JDK and Apache Ant are required; Maven is used for dependency reso - 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/`. diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index eb0f6927..b29bed2f 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -496,15 +496,15 @@ impl NodeBoxApp { // Collect recent files to avoid borrow issues let recent_files_list = self.recent_files.files(); - egui::menu::bar(ui, |ui| { + egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { self.state.new_document(); - ui.close_menu(); + ui.close(); } if ui.button("Open...").clicked() { self.open_file(); - ui.close_menu(); + ui.close(); } ui.menu_button("Open Recent", |ui| { if recent_files_list.is_empty() { @@ -519,7 +519,7 @@ impl NodeBoxApp { .unwrap_or("Unknown"); if ui.button(display_name).clicked() { path_to_open = Some(path.clone()); - ui.close_menu(); + ui.close(); } } if let Some(path) = path_to_open { @@ -529,25 +529,25 @@ impl NodeBoxApp { } if ui.add_enabled(!recent_files_list.is_empty(), egui::Button::new("Clear Recent")).clicked() { self.clear_recent_files(); - ui.close_menu(); + ui.close(); } }); if ui.button("Save").clicked() { self.save_file(); - ui.close_menu(); + ui.close(); } if ui.button("Save As...").clicked() { self.save_file_as(); - ui.close_menu(); + ui.close(); } ui.separator(); if ui.button("Export SVG...").clicked() { self.export_svg(); - ui.close_menu(); + ui.close(); } if ui.button("Export PNG...").clicked() { self.export_png(); - ui.close_menu(); + ui.close(); } ui.separator(); if ui.button("Quit").clicked() { @@ -567,7 +567,7 @@ impl NodeBoxApp { self.previous_library_hash = Self::hash_library(&self.state.library); self.render_pending = true; } - ui.close_menu(); + ui.close(); } let redo_text = if self.history.can_redo() { format!("Redo ({})", self.history.redo_count()) @@ -580,26 +580,26 @@ impl NodeBoxApp { self.previous_library_hash = Self::hash_library(&self.state.library); self.render_pending = true; } - ui.close_menu(); + ui.close(); } ui.separator(); if ui.button("Delete Selected").clicked() { - ui.close_menu(); + ui.close(); } }); ui.menu_button("View", |ui| { if ui.button("Zoom In").clicked() { self.viewer_pane.zoom_in(); - ui.close_menu(); + ui.close(); } if ui.button("Zoom Out").clicked() { self.viewer_pane.zoom_out(); - ui.close_menu(); + ui.close(); } if ui.button("Fit to Window").clicked() { self.viewer_pane.fit_to_window(); - ui.close_menu(); + ui.close(); } ui.separator(); ui.checkbox(&mut self.viewer_pane.show_handles, "Show Handles"); @@ -611,7 +611,7 @@ impl NodeBoxApp { ui.menu_button("Help", |ui| { if ui.button("About NodeBox").clicked() { self.state.show_about = true; - ui.close_menu(); + ui.close(); } }); }); From 0708f0348138092e4d4d5d77f3115861c115ae6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:01:43 +0000 Subject: [PATCH 082/100] Group undo entries for continuous drag operations. Previously, every per-frame change during a drag (handle drag, node drag, parameter label drag) created a separate undo entry, requiring many Ctrl+Z presses to undo a single gesture. Now all changes within a drag are collapsed into a single undo entry via begin/end undo groups on the History struct. https://claude.ai/code/session_01BGyKyaR55Mgbw1R1V4kbLw --- crates/nodebox-gui/src/app.rs | 198 ++++++++++++++++++++++ crates/nodebox-gui/src/history.rs | 42 +++++ crates/nodebox-gui/src/network_view.rs | 5 + crates/nodebox-gui/src/parameter_panel.rs | 16 ++ crates/nodebox-gui/src/viewer_pane.rs | 6 + crates/nodebox-gui/tests/history_tests.rs | 151 +++++++++++++++++ 6 files changed, 418 insertions(+) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index b29bed2f..a8a72ff4 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -53,6 +53,8 @@ pub struct NodeBoxApp { /// 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, } impl NodeBoxApp { @@ -122,6 +124,7 @@ impl NodeBoxApp { native_menu, recent_files, pending_connection: None, + was_dragging: false, } } @@ -203,6 +206,7 @@ impl NodeBoxApp { native_menu, recent_files, pending_connection: None, + was_dragging: false, } } @@ -234,6 +238,7 @@ impl NodeBoxApp { native_menu: None, recent_files: RecentFiles::new(), pending_connection: None, + was_dragging: false, } } @@ -266,6 +271,7 @@ impl NodeBoxApp { native_menu: None, recent_files: RecentFiles::new(), pending_connection: None, + was_dragging: false, } } @@ -714,6 +720,10 @@ impl eframe::App for NodeBoxApp { ctx.request_repaint(); } + // Capture pre-frame library state for undo group detection. + // This is a cheap Arc clone (just a pointer + refcount increment). + let pre_frame_library = Arc::clone(&self.state.library); + // 4. Right side panel containing Parameters (top) and Network (bottom) // // Style the built-in separator: egui uses noninteractive.bg_stroke (normal), @@ -920,6 +930,26 @@ impl eframe::App for NodeBoxApp { } } + // 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); + } + 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 so check_for_changes doesn't create a duplicate entry. + self.previous_library_hash = Self::hash_library(&self.state.library); + } + self.was_dragging = is_dragging; + // Check for state changes and save to history self.check_for_changes(); @@ -1312,4 +1342,172 @@ mod tests { "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); + + assert_eq!(app.history.undo_count(), 0); + + // Simulate drag start: capture pre-drag state + let pre_drag_library = Arc::clone(&app.state.library); + app.history.begin_undo_group(&pre_drag_library); + + // 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); + + // 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); + + // Simulate drag: width goes from 100 → 200 + let pre_drag = Arc::clone(&app.state.library); + app.history.begin_undo_group(&pre_drag); + + 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 + if let Some(restored) = app.history.undo(&app.state.library) { + 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); + + // 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); + + // Drag operation + let pre_drag = Arc::clone(&app.state.library); + app.history.begin_undo_group(&pre_drag); + 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); + + // 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); + } } diff --git a/crates/nodebox-gui/src/history.rs b/crates/nodebox-gui/src/history.rs index d195488f..7eeeb600 100644 --- a/crates/nodebox-gui/src/history.rs +++ b/crates/nodebox-gui/src/history.rs @@ -15,6 +15,9 @@ pub struct History { /// 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>, } impl Default for History { @@ -30,6 +33,7 @@ impl History { undo_stack: Vec::new(), redo_stack: Vec::new(), last_saved_state: None, + group_start_state: None, } } @@ -45,7 +49,14 @@ impl History { /// 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) { + if self.group_start_state.is_some() { + return; + } + self.undo_stack.push(Arc::clone(library)); // Clear redo stack when new changes are made @@ -57,6 +68,37 @@ impl History { } } + /// 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) { + if self.group_start_state.is_none() { + self.group_start_state = Some(Arc::clone(library)); + } + } + + /// 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_state) = self.group_start_state.take() { + if start_state.as_ref() != current.as_ref() { + self.undo_stack.push(start_state); + 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. /// Call this to restore the library to its previous state. pub fn undo(&mut self, current: &Arc) -> Option> { diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index 4c8e540c..3098ee49 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -122,6 +122,11 @@ impl NetworkView { } } + /// 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 diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index c688df95..8d5ad568 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -27,6 +27,8 @@ pub struct ParameterPanel { 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 { @@ -47,9 +49,15 @@ impl ParameterPanel { 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, @@ -58,6 +66,11 @@ impl ParameterPanel { port: &dyn Port, 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); @@ -314,6 +327,7 @@ impl ParameterPanel { } if response.drag_started() { drag_started = true; + self.is_dragging = true; } if response.dragged() { drag_delta_x = response.drag_delta().x; @@ -930,6 +944,7 @@ impl ParameterPanel { if response.drag_started() { self.drag_accumulator = 0.0; + self.is_dragging = true; } if response.dragged() { let modifier = Self::drag_modifier(ui); @@ -1079,6 +1094,7 @@ impl ParameterPanel { if response.drag_started() { self.drag_accumulator = 0.0; + self.is_dragging = true; } if response.dragged() { let modifier = Self::drag_modifier(ui); diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index 8ef9ce53..eab18a91 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -273,6 +273,12 @@ impl ViewerPane { } } + /// 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()) + } + /// Get the current pan offset. #[allow(dead_code)] pub fn pan(&self) -> Vec2 { diff --git a/crates/nodebox-gui/tests/history_tests.rs b/crates/nodebox-gui/tests/history_tests.rs index d6162126..8b46f172 100644 --- a/crates/nodebox-gui/tests/history_tests.rs +++ b/crates/nodebox-gui/tests/history_tests.rs @@ -232,3 +232,154 @@ fn test_history_redo_on_empty_returns_none() { let result = history.redo(&library); 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); + + // 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); + } + + // 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); + + // Simulate intermediate changes during drag + let library_v2 = create_test_library(25.0); + history.save_state(&library_v2); + let library_v3 = create_test_library(50.0); + history.save_state(&library_v3); + + // 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).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); + 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); + let library_v2 = create_test_library(50.0); + history.undo(&library_v2); + assert!(history.can_redo()); + + // Now perform a group operation + let library_v3 = create_test_library(100.0); + history.begin_undo_group(&library_v3); + 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); + + // Second begin_undo_group should be ignored (first wins) + history.begin_undo_group(&library_b); + + 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).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); + 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); + + // 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); + assert!(history.is_in_group()); + + history.end_undo_group(&library); + assert!(!history.is_in_group()); +} From 62e1dfc7c63629476bff4a024733ca1d281d5192 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 19:54:07 +0000 Subject: [PATCH 083/100] Fix undo to save pre-mutation state and freeze during undo groups. The core bug was that check_for_changes() saved the post-mutation library state, so undoing restored the same state the user was already looking at. Now it saves the previous_library snapshot (captured at end of prior frame) so undo correctly restores the pre-mutation state. Also fixes: - previous_library is frozen during undo groups so intermediate drag frames don't corrupt the pre-drag snapshot - Tests use new_for_testing_empty() to avoid name collision with the demo library's rect1 node - All undo/redo handlers sync previous_library after restoring https://claude.ai/code/session_01BGyKyaR55Mgbw1R1V4kbLw --- crates/nodebox-gui/src/app.rs | 255 +++++++++++++++++++++++++++++++++- 1 file changed, 251 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index a8a72ff4..fad3ae26 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -40,6 +40,8 @@ pub struct NodeBoxApp { 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, /// Background render worker. render_worker: RenderWorkerHandle, /// State tracking for render requests. @@ -105,6 +107,7 @@ impl NodeBoxApp { } let hash = Self::hash_library(&state.library); + let prev_library = Arc::clone(&state.library); Self { port, project_context, @@ -118,6 +121,7 @@ impl NodeBoxApp { icon_cache: IconCache::new(), history: History::new(), previous_library_hash: hash, + previous_library: prev_library, render_worker: RenderWorkerHandle::spawn(), render_state: RenderState::new(), render_pending: true, @@ -187,6 +191,7 @@ impl NodeBoxApp { } let hash = Self::hash_library(&state.library); + let prev_library = Arc::clone(&state.library); Self { port, project_context, @@ -200,6 +205,7 @@ impl NodeBoxApp { icon_cache: IconCache::new(), history: History::new(), previous_library_hash: hash, + previous_library: prev_library, render_worker: RenderWorkerHandle::spawn(), render_state: RenderState::new(), render_pending: true, @@ -219,6 +225,7 @@ impl NodeBoxApp { 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(nodebox_port::DesktopPort::new()), project_context: ProjectContext::new_unsaved(), @@ -232,6 +239,7 @@ impl NodeBoxApp { icon_cache: IconCache::new(), history: History::new(), previous_library_hash: hash, + previous_library: prev_library, render_worker: RenderWorkerHandle::spawn(), render_state: RenderState::new(), render_pending: false, @@ -252,6 +260,7 @@ impl NodeBoxApp { 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(nodebox_port::DesktopPort::new()), project_context: ProjectContext::new_unsaved(), @@ -265,6 +274,7 @@ impl NodeBoxApp { icon_cache: IconCache::new(), history: History::new(), previous_library_hash: hash, + previous_library: prev_library, render_worker: RenderWorkerHandle::spawn(), render_state: RenderState::new(), render_pending: false, @@ -341,11 +351,14 @@ impl NodeBoxApp { #[cfg(test)] #[allow(dead_code)] pub fn update_for_testing(&mut self) { - // Check for changes and save to history + // 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.state.library); + self.history.save_state(&self.previous_library); self.previous_library_hash = current_hash; + if !self.history.is_in_group() { + self.previous_library = Arc::clone(&self.state.library); + } self.state.dirty = true; } // Synchronously evaluate using the app's port @@ -450,11 +463,21 @@ impl NodeBoxApp { } /// 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.state.library); + self.history.save_state(&self.previous_library); self.previous_library_hash = current_hash; + // Only update previous_library when not in an undo group. + // During a group, the group_start_state tracks the pre-drag snapshot + // and previous_library 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.state.dirty = true; self.render_pending = true; // Queue async render } @@ -475,6 +498,7 @@ impl NodeBoxApp { 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.previous_library = Arc::clone(&self.state.library); self.render_pending = true; } } @@ -482,6 +506,7 @@ impl NodeBoxApp { 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.previous_library = Arc::clone(&self.state.library); self.render_pending = true; } } @@ -571,6 +596,7 @@ impl NodeBoxApp { 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.previous_library = Arc::clone(&self.state.library); self.render_pending = true; } ui.close(); @@ -584,6 +610,7 @@ impl NodeBoxApp { 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.previous_library = Arc::clone(&self.state.library); self.render_pending = true; } ui.close(); @@ -919,6 +946,7 @@ impl eframe::App for NodeBoxApp { 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.previous_library = Arc::clone(&self.state.library); self.render_pending = true; } } @@ -926,6 +954,7 @@ impl eframe::App for NodeBoxApp { 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.previous_library = Arc::clone(&self.state.library); self.render_pending = true; } } @@ -945,8 +974,9 @@ impl eframe::App for NodeBoxApp { 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 so check_for_changes doesn't create a duplicate entry. + // 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.was_dragging = is_dragging; @@ -1281,6 +1311,7 @@ mod tests { // 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) @@ -1360,6 +1391,7 @@ mod tests { ); 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); @@ -1385,6 +1417,7 @@ mod tests { // 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"); @@ -1405,6 +1438,7 @@ mod tests { ); 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); @@ -1451,6 +1485,7 @@ mod tests { ); 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") { @@ -1486,6 +1521,7 @@ mod tests { ); 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); @@ -1498,6 +1534,7 @@ mod tests { 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") { @@ -1510,4 +1547,214 @@ mod tests { // 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 + if let Some(restored) = app.history.undo(&app.state.library) { + 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 + if let Some(restored) = app.history.undo(&app.state.library) { + 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 + if let Some(restored) = app.history.undo(&app.state.library) { + 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 + if let Some(restored) = app.history.undo(&app.state.library) { + 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); + app.history.begin_undo_group(&pre_drag); + 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) + if let Some(restored) = app.history.undo(&app.state.library) { + 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) + if let Some(restored) = app.history.undo(&app.state.library) { + app.state.library = restored; + } + assert_eq!(app.state.library.root.rendered_child.as_deref(), Some("ellipse1"), + "Second undo should restore rendered to ellipse1"); + } } From 0da92b0a972f1b1f359935d84518a9ca29c78585 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 20:40:50 +0000 Subject: [PATCH 084/100] Track and restore node selection in undo/redo. - Add SelectionSnapshot type to history.rs; store selection alongside library state in undo/redo stacks so that undo restores both the node graph and what was selected. - Add previous_selection field (mirrors previous_library) so check_for_changes saves the pre-mutation selection, not the current one. - Add set_selected() to NetworkView for restoring private selection state. - Add apply_selection() helper that validates restored selections, pruning references to nodes that no longer exist in the library. - Validate node existence in parameter_panel.show() before rendering, fixing both the "Node not found" error and the duplicate PARAMETERS header that appeared when a selected node was deleted by undo. https://claude.ai/code/session_01BGyKyaR55Mgbw1R1V4kbLw --- crates/nodebox-gui/src/app.rs | 250 +++++++++++++++++++--- crates/nodebox-gui/src/history.rs | 58 +++-- crates/nodebox-gui/src/lib.rs | 2 +- crates/nodebox-gui/src/network_view.rs | 5 + crates/nodebox-gui/src/parameter_panel.rs | 9 + crates/nodebox-gui/tests/history_tests.rs | 158 ++++++++++---- 6 files changed, 387 insertions(+), 95 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index fad3ae26..3ef9f31b 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use crate::address_bar::{AddressBar, AddressBarAction}; use crate::animation_bar::{AnimationBar, AnimationEvent}; use crate::components; -use crate::history::History; +use crate::history::{History, SelectionSnapshot}; use crate::icon_cache::IconCache; use crate::native_menu::{MenuAction, NativeMenuHandle}; use crate::notification_banner; @@ -42,6 +42,8 @@ pub struct NodeBoxApp { 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. @@ -122,6 +124,7 @@ impl NodeBoxApp { 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, @@ -206,6 +209,7 @@ impl NodeBoxApp { 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, @@ -240,6 +244,7 @@ impl NodeBoxApp { 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, @@ -275,6 +280,7 @@ impl NodeBoxApp { 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, @@ -354,10 +360,11 @@ impl NodeBoxApp { // 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.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; } @@ -462,6 +469,29 @@ impl NodeBoxApp { } } + /// 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) @@ -469,14 +499,15 @@ impl NodeBoxApp { 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.history.save_state(&self.previous_library, &self.previous_selection); self.previous_library_hash = current_hash; - // Only update previous_library when not in an undo group. + // 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_library must stay frozen so that the next non-grouped + // 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 @@ -495,18 +526,24 @@ impl NodeBoxApp { MenuAction::ExportPng => self.export_png(), MenuAction::ExportSvg => self.export_svg(), MenuAction::Undo => { - if let Some(previous) = self.history.undo(&self.state.library) { + 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 => { - if let Some(next) = self.history.redo(&self.state.library) { + 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; } } @@ -593,10 +630,13 @@ impl NodeBoxApp { "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) { + 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(); @@ -607,10 +647,13 @@ impl NodeBoxApp { "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) { + 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(); @@ -747,9 +790,10 @@ impl eframe::App for NodeBoxApp { ctx.request_repaint(); } - // Capture pre-frame library state for undo group detection. - // This is a cheap Arc clone (just a pointer + refcount increment). + // 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) // @@ -943,18 +987,24 @@ impl eframe::App for NodeBoxApp { } if do_undo { - if let Some(previous) = self.history.undo(&self.state.library) { + 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 { - if let Some(next) = self.history.redo(&self.state.library) { + 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; } } @@ -969,7 +1019,7 @@ impl eframe::App for NodeBoxApp { 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); + 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). @@ -977,6 +1027,7 @@ impl eframe::App for NodeBoxApp { // 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; @@ -1397,7 +1448,8 @@ mod tests { // Simulate drag start: capture pre-drag state let pre_drag_library = Arc::clone(&app.state.library); - app.history.begin_undo_group(&pre_drag_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 { @@ -1442,7 +1494,8 @@ mod tests { // Simulate drag: width goes from 100 → 200 let pre_drag = Arc::clone(&app.state.library); - app.history.begin_undo_group(&pre_drag); + 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); @@ -1462,7 +1515,8 @@ mod tests { assert!((current_width - 200.0).abs() < 0.001); // Undo should restore to width=100 - if let Some(restored) = app.history.undo(&app.state.library) { + 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() @@ -1525,7 +1579,8 @@ mod tests { // Drag operation let pre_drag = Arc::clone(&app.state.library); - app.history.begin_undo_group(&pre_drag); + 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); @@ -1581,8 +1636,11 @@ mod tests { assert!((width - 200.0).abs() < 0.001); // Undo should restore width=100 - if let Some(restored) = app.history.undo(&app.state.library) { - app.state.library = restored; + { + 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(); @@ -1611,8 +1669,11 @@ mod tests { "Node should have been added"); // Undo should remove the node - if let Some(restored) = app.history.undo(&app.state.library) { - app.state.library = restored; + { + 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"); @@ -1654,8 +1715,11 @@ mod tests { "Connection should exist"); // Undo should remove the connection - if let Some(restored) = app.history.undo(&app.state.library) { - app.state.library = restored; + { + 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"); @@ -1687,8 +1751,11 @@ mod tests { assert_eq!(app.state.library.root.rendered_child.as_deref(), Some("rect1")); // Undo should restore rendered to ellipse1 - if let Some(restored) = app.history.undo(&app.state.library) { - app.state.library = restored; + { + 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"); @@ -1722,7 +1789,8 @@ mod tests { // Operation 2: Drag (change width from 50 to 200) let pre_drag = Arc::clone(&app.state.library); - app.history.begin_undo_group(&pre_drag); + 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") { @@ -1740,8 +1808,11 @@ mod tests { "Should have exactly 2 undo entries, not more"); // Undo #1: Should undo the drag (width back to 50) - if let Some(restored) = app.history.undo(&app.state.library) { - app.state.library = restored; + { + 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(); @@ -1751,10 +1822,127 @@ mod tests { "First undo should keep rendered=rect1"); // Undo #2: Should undo the rendered node change (back to ellipse1) - if let Some(restored) = app.history.undo(&app.state.library) { - app.state.library = restored; + { + 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/history.rs b/crates/nodebox-gui/src/history.rs index 7eeeb600..859e00e2 100644 --- a/crates/nodebox-gui/src/history.rs +++ b/crates/nodebox-gui/src/history.rs @@ -1,23 +1,33 @@ //! 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>, + undo_stack: Vec<(Arc, SelectionSnapshot)>, /// Future states (redo stack). - redo_stack: Vec>, + 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>, + group_start_state: Option<(Arc, SelectionSnapshot)>, } impl Default for History { @@ -52,12 +62,12 @@ impl History { /// /// 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) { + 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)); + self.undo_stack.push((Arc::clone(library), selection.clone())); // Clear redo stack when new changes are made self.redo_stack.clear(); @@ -73,9 +83,9 @@ impl History { /// 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) { + 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)); + self.group_start_state = Some((Arc::clone(library), selection.clone())); } } @@ -83,9 +93,9 @@ impl History { /// 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_state) = self.group_start_state.take() { - if start_state.as_ref() != current.as_ref() { - self.undo_stack.push(start_state); + 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); @@ -99,24 +109,32 @@ impl History { self.group_start_state.is_some() } - /// Undo the last change, returning the previous state. + /// 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) -> Option> { - if let Some(previous) = self.undo_stack.pop() { + 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)); - Some(previous) + 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. - pub fn redo(&mut self, current: &Arc) -> Option> { - if let Some(next) = self.redo_stack.pop() { + /// 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)); - Some(next) + self.undo_stack.push((Arc::clone(current), current_selection.clone())); + Some((next_lib, next_sel)) } else { None } diff --git a/crates/nodebox-gui/src/lib.rs b/crates/nodebox-gui/src/lib.rs index 04149795..f7ad3b4a 100644 --- a/crates/nodebox-gui/src/lib.rs +++ b/crates/nodebox-gui/src/lib.rs @@ -51,7 +51,7 @@ pub mod vello_viewer; // Re-export key types for testing and external use pub use app::NodeBoxApp; -pub use history::History; +pub use history::{History, SelectionSnapshot}; pub use state::{populate_default_ports, AppState, Notification, NotificationLevel}; // Re-export commonly used types from dependencies diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-gui/src/network_view.rs index 3098ee49..0a5d0896 100644 --- a/crates/nodebox-gui/src/network_view.rs +++ b/crates/nodebox-gui/src/network_view.rs @@ -132,6 +132,11 @@ impl NetworkView { &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) { diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index 8d5ad568..a3994faf 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -74,6 +74,15 @@ impl ParameterPanel { // 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 diff --git a/crates/nodebox-gui/tests/history_tests.rs b/crates/nodebox-gui/tests/history_tests.rs index 8b46f172..16343ca4 100644 --- a/crates/nodebox-gui/tests/history_tests.rs +++ b/crates/nodebox-gui/tests/history_tests.rs @@ -3,7 +3,7 @@ mod common; use std::sync::Arc; -use nodebox_gui::{History, Node, NodeLibrary, Port}; +use nodebox_gui::{History, SelectionSnapshot, Node, NodeLibrary, Port}; /// Create a simple test library with an ellipse. fn create_test_library(x: f64) -> Arc { @@ -21,6 +21,10 @@ fn create_test_library(x: f64) -> Arc { Arc::new(library) } +fn default_sel() -> SelectionSnapshot { + SelectionSnapshot::default() +} + #[test] fn test_history_new_is_empty() { let history = History::new(); @@ -35,7 +39,7 @@ fn test_history_save_enables_undo() { let mut history = History::new(); let library = create_test_library(0.0); - history.save_state(&library); + history.save_state(&library, &default_sel()); assert!(history.can_undo()); assert!(!history.can_redo()); @@ -48,13 +52,13 @@ fn test_history_undo_restores_previous_state() { // Save initial state let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); + 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).unwrap(); + let (restored, _) = history.undo(&library_v2, &default_sel()).unwrap(); // Check that the x value was restored let node = restored.root.child("ellipse1").unwrap(); @@ -67,10 +71,10 @@ fn test_history_undo_enables_redo() { let mut history = History::new(); let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); + history.save_state(&library_v1, &default_sel()); let library_v2 = create_test_library(100.0); - history.undo(&library_v2); + history.undo(&library_v2, &default_sel()); assert!(history.can_redo()); assert_eq!(history.redo_count(), 1); @@ -81,15 +85,15 @@ 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); + 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).unwrap(); + let (after_undo, _) = history.undo(&library_v2, &default_sel()).unwrap(); // Redo should return v2 - let after_redo = history.redo(&after_undo).unwrap(); + 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(); @@ -101,17 +105,17 @@ 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); + history.save_state(&library_v1, &default_sel()); let library_v2 = create_test_library(100.0); // Undo to enable redo - history.undo(&library_v2); + 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); + history.save_state(&library_v3, &default_sel()); // Redo should now be unavailable assert!(!history.can_redo()); @@ -124,22 +128,22 @@ fn test_history_multiple_undos() { // Create and save multiple states let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); + history.save_state(&library_v1, &default_sel()); let library_v2 = create_test_library(50.0); - history.save_state(&library_v2); + 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).unwrap(); + 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).unwrap(); + 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); @@ -153,26 +157,26 @@ fn test_history_multiple_redos() { let mut history = History::new(); let library_v1 = create_test_library(0.0); - history.save_state(&library_v1); + history.save_state(&library_v1, &default_sel()); let library_v2 = create_test_library(50.0); - history.save_state(&library_v2); + 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).unwrap(); - let after_second_undo = history.undo(&after_first_undo).unwrap(); + 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).unwrap(); + 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).unwrap(); + 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); @@ -186,9 +190,9 @@ 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); + 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); @@ -220,7 +224,7 @@ 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); + let result = history.undo(&library, &default_sel()); assert!(result.is_none()); } @@ -229,7 +233,7 @@ 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); + let result = history.redo(&library, &default_sel()); assert!(result.is_none()); } @@ -241,12 +245,12 @@ fn test_save_state_suppressed_during_group() { let library_v1 = create_test_library(0.0); // Begin a group - history.begin_undo_group(&library_v1); + 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); + history.save_state(&lib, &default_sel()); } // End the group with the final state @@ -263,13 +267,13 @@ fn test_group_undo_restores_pre_group_state() { let library_v1 = create_test_library(0.0); // Begin group with state A (x=0) - history.begin_undo_group(&library_v1); + 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); + history.save_state(&library_v2, &default_sel()); let library_v3 = create_test_library(50.0); - history.save_state(&library_v3); + history.save_state(&library_v3, &default_sel()); // End group with final state (x=50) history.end_undo_group(&library_v3); @@ -277,7 +281,7 @@ fn test_group_undo_restores_pre_group_state() { assert_eq!(history.undo_count(), 1); // Undo should restore the pre-group state (x=0) - let restored = history.undo(&library_v3).unwrap(); + 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); @@ -301,7 +305,7 @@ fn test_no_op_group_creates_no_entry() { let library = create_test_library(0.0); // Begin and end group with the same state (no actual changes) - history.begin_undo_group(&library); + history.begin_undo_group(&library, &default_sel()); history.end_undo_group(&library); // No undo entry should be created since nothing changed @@ -314,14 +318,14 @@ fn test_group_clears_redo_stack() { // 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); + history.save_state(&library_v1, &default_sel()); let library_v2 = create_test_library(50.0); - history.undo(&library_v2); + 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); + history.begin_undo_group(&library_v3, &default_sel()); let library_v4 = create_test_library(200.0); history.end_undo_group(&library_v4); @@ -337,16 +341,16 @@ fn test_nested_begin_group_keeps_first() { let library_b = create_test_library(50.0); // First begin_undo_group captures state A - history.begin_undo_group(&library_a); + history.begin_undo_group(&library_a, &default_sel()); // Second begin_undo_group should be ignored (first wins) - history.begin_undo_group(&library_b); + 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).unwrap(); + 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); @@ -358,13 +362,13 @@ fn test_group_followed_by_normal_saves() { // Do a grouped operation let library_v1 = create_test_library(0.0); - history.begin_undo_group(&library_v1); + 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); + history.save_state(&library_v3, &default_sel()); // Should have 2 entries: the group + the normal save assert_eq!(history.undo_count(), 2); @@ -377,9 +381,77 @@ fn test_is_in_group() { assert!(!history.is_in_group()); - history.begin_undo_group(&library); + 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())); +} From f9f1638b52e74f01dd564629d3f66ce877e7b7b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 21:02:33 +0000 Subject: [PATCH 085/100] Fix distribute node receiving shapes one at a time instead of as a list. The shapes input port was missing .with_port_range(PortRange::List), so the evaluation engine treated it as a VALUE-range port and iterated over shapes individually. Distributing 1 shape is a no-op, so nothing happened. https://claude.ai/code/session_01RXykJLXjiS6V7MVCstvo1i --- crates/nodebox-gui/src/node_library.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index e351db48..d2c4aa81 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -1248,7 +1248,7 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, } "distribute" => { node = node - .with_input(Port::geometry("shapes")) + .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"), From 8a7df0cc41b70688abe84720105d5b0f05e4a49c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 21:05:40 +0000 Subject: [PATCH 086/100] Add freehand drawing handle for canvas interaction. When a freehand node is selected, the viewer canvas now supports direct mouse drawing: press to start a new contour, drag to add points, release to finish the stroke. The cursor shows a crosshair with a 5px circle indicator, matching the Java FreehandHandle behavior. Undo grouping is handled automatically via the existing drag detection system (begin_undo_group/end_undo_group), so each stroke is a single undo entry. https://claude.ai/code/session_011SGGxrHu2QGENF6nQ7n2KD --- crates/nodebox-gui/src/app.rs | 23 +++++ crates/nodebox-gui/src/viewer_pane.rs | 137 +++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 3ef9f31b..767c537d 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -917,6 +917,10 @@ impl eframe::App for NodeBoxApp { 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 => {} } }); @@ -1060,6 +1064,17 @@ impl NodeBoxApp { } } + /// 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 { @@ -1285,6 +1300,10 @@ mod tests { 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 => {} } @@ -1333,6 +1352,10 @@ mod tests { 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 => {} } diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index eab18a91..0e9f4696 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -32,6 +32,55 @@ pub enum HandleResult { 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. @@ -238,6 +287,8 @@ pub struct ViewerPane { 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 { @@ -270,6 +321,7 @@ impl ViewerPane { data_view_mode: DataViewMode::Points, preferred_geometry_tab: ViewerTab::Viewer, visual_tab_available: true, + freehand_state: None, } } @@ -277,6 +329,7 @@ impl ViewerPane { 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. @@ -660,6 +713,67 @@ impl ViewerPane { 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 @@ -1613,7 +1727,26 @@ impl ViewerPane { 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; + } } } @@ -1631,11 +1764,13 @@ impl ViewerPane { } 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; } } } From 5f250e7ee1875ffe3a94386f831868cdbfd7e523 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 21:13:36 +0000 Subject: [PATCH 087/100] Fix file dialog to use appropriate filters per node type. The Widget::File handler was hardcoded to FileFilter::svg(). Now it selects filters based on the node prototype: CSV files for import_csv, text files for import_text, SVG for import_svg, and no filter for unknown nodes. https://claude.ai/code/session_01Ssbet1itX47TACaYWBM1Wo --- crates/nodebox-gui/src/parameter_panel.rs | 14 ++++++++++++-- crates/nodebox-port/src/lib.rs | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index c688df95..97f18a94 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -143,6 +143,7 @@ impl ParameterPanel { node_port, is_connected, &node_name_clone, + node_prototype.as_deref(), port, project_context, ); @@ -338,6 +339,7 @@ impl ParameterPanel { port: &mut nodebox_core::node::Port, is_connected: bool, node_name: &str, + node_prototype: Option<&str>, io_port: &dyn Port, project_context: &ProjectContext, ) { @@ -386,7 +388,7 @@ impl ParameterPanel { 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, io_port, project_context); + 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() { @@ -457,6 +459,7 @@ impl ParameterPanel { ui: &mut egui::Ui, port: &mut nodebox_core::node::Port, node_name: &str, + node_prototype: Option<&str>, io_port: &dyn Port, project_context: &ProjectContext, ) { @@ -742,10 +745,17 @@ impl ParameterPanel { &["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 + }; // Use sandboxed file dialog through Port match io_port.show_open_file_dialog( project_context, - &[FileFilter::svg()], + &filters, ) { Ok(Some(relative_path)) => { // Store the relative path diff --git a/crates/nodebox-port/src/lib.rs b/crates/nodebox-port/src/lib.rs index 0cd1b9f9..8ee58446 100644 --- a/crates/nodebox-port/src/lib.rs +++ b/crates/nodebox-port/src/lib.rs @@ -316,6 +316,16 @@ impl FileFilter { 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. From 26f14e4dec1e477a4f65c38ad7f5ca69c5269796 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 21:26:19 +0000 Subject: [PATCH 088/100] Restyle file widget to match string/number dragger appearance. Uses non-interactive TextEdit for the filename (same as string widget), with a "..." suffix on the right. The entire row is clickable and shows the same hover effect as other widgets. Clicking anywhere opens the file picker dialog. https://claude.ai/code/session_01Ssbet1itX47TACaYWBM1Wo --- crates/nodebox-gui/src/parameter_panel.rs | 82 +++++++++++++---------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-gui/src/parameter_panel.rs index 97f18a94..4f8799d4 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-gui/src/parameter_panel.rs @@ -688,57 +688,72 @@ impl ParameterPanel { } Widget::File => { if let Value::String(ref mut path) = port.value { - // Show filename or placeholder + // 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) + std::path::Path::new(path.as_str()) .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| path.clone()) }; - let galley = ui.painter().layout_no_wrap( - display_text, - egui::FontId::proportional(11.0), - if path.is_empty() { theme::TEXT_DISABLED } else { theme::VALUE_TEXT }, + // 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 rect = ui.available_rect_before_wrap(); - let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); - ui.painter().galley(text_pos, galley.clone(), theme::VALUE_TEXT); - - // Add browse button after the text - let button_x = rect.left() + galley.size().x + 8.0; - let button_rect = egui::Rect::from_min_size( - egui::pos2(button_x, rect.center().y - 8.0), - egui::vec2(16.0, 16.0), - ); - - let button_response = ui.allocate_rect(button_rect, Sense::click()); - - // Draw folder icon or "..." button - let button_color = if button_response.hovered() { - theme::TEXT_BRIGHT - } else { - theme::TEXT_NORMAL - }; + let dots_color = theme::TEXT_SUBDUED; ui.painter().text( - button_rect.center(), + dots_rect.center(), egui::Align2::CENTER_CENTER, - "…", - egui::FontId::proportional(14.0), - button_color, + "...", + egui::FontId::proportional(theme::FONT_SIZE_SMALL), + dots_color, ); - if button_response.hovered() { + // 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); } - if button_response.clicked() { + // Click to open file picker + if response.clicked() { // Check if project is saved first if !project_context.is_saved() { - // Show message to save project first let _ = io_port.show_message_dialog( "Save Project First", "Please save your project before importing files.", @@ -752,18 +767,15 @@ impl ParameterPanel { Some("corevector.import_svg") => vec![FileFilter::svg()], _ => vec![], // No filter = all files }; - // Use sandboxed file dialog through Port match io_port.show_open_file_dialog( project_context, &filters, ) { Ok(Some(relative_path)) => { - // Store the relative path *path = relative_path.to_string(); } Ok(None) => {} // User cancelled Err(PortError::SandboxViolation) => { - // File is outside project directory let _ = io_port.show_message_dialog( "File Outside Project", "Please copy the file to your project folder first.", From 245ba1e8ee2ca1a6a9c0e1f9f6bfdc2c9a5c0192 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 21:41:40 +0000 Subject: [PATCH 089/100] Add multi-column data table view for CSV/data rows in viewer pane The data viewer now renders DataRows output as a proper spreadsheet-style table with separate columns for each CSV field, instead of flat strings. https://claude.ai/code/session_01Ssbet1itX47TACaYWBM1Wo --- crates/nodebox-gui/src/eval.rs | 21 +++++ crates/nodebox-gui/src/viewer_pane.rs | 119 ++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index 58d64d26..f796b17a 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -143,6 +143,27 @@ impl NodeOutput { 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 { diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-gui/src/viewer_pane.rs index 8ef9ce53..c83f7900 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-gui/src/viewer_pane.rs @@ -3,7 +3,10 @@ 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_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; @@ -824,6 +827,13 @@ impl ViewerPane { 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 { @@ -958,6 +968,115 @@ impl ViewerPane { }); } + /// 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| { From 4170891bb5386a74450538b745cf1c850ec96040 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 21:54:02 +0000 Subject: [PATCH 090/100] Wire up keys and zip_map nodes using DataRow support Both nodes were stubbed out waiting for map type support. Now that DataRow/DataRows exist in NodeOutput, replace the stubs with real implementations and register them in the node library. https://claude.ai/code/session_01Ssbet1itX47TACaYWBM1Wo --- crates/nodebox-gui/src/eval.rs | 28 ++++++++++++++++++++++---- crates/nodebox-gui/src/node_library.rs | 24 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-gui/src/eval.rs index f796b17a..7fc7a1d6 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-gui/src/eval.rs @@ -2023,10 +2023,30 @@ fn execute_node( } } - "list.keys" | "list.zip_map" => { - // These require Map support, return None for now - log::warn!("Map-based list node not yet fully supported: {}", proto); - Ok(NodeOutput::None) + "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)) } // ======================== diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index 13f57c5f..cc0eb7c8 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -766,6 +766,18 @@ pub const NODE_TEMPLATES: &[NodeTemplate] = &[ 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 // ======================== @@ -1785,6 +1797,18 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, .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 // ======================== From b7145edc27ed045efa5179779ccb9c0479d0b5ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 22:10:23 +0000 Subject: [PATCH 091/100] Fix lookup output type to allow numeric connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change lookup's registered output from PortType::String to PortType::Data (matching the ndbx definition) and add Data → Float/Int/Point compatibility rules. The runtime already returns NodeOutput::Float for numeric columns; this fix lets the connection validator accept those wires. https://claude.ai/code/session_01Ssbet1itX47TACaYWBM1Wo --- crates/nodebox-core/src/node/port.rs | 7 +++++++ crates/nodebox-gui/src/node_library.rs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/nodebox-core/src/node/port.rs b/crates/nodebox-core/src/node/port.rs index 8ad084f0..beb74852 100644 --- a/crates/nodebox-core/src/node/port.rs +++ b/crates/nodebox-core/src/node/port.rs @@ -101,6 +101,13 @@ impl PortType { return true; } + // Data output can connect to numeric inputs (e.g. lookup returns Float for numeric columns) + if matches!(output_type, PortType::Data) + && matches!(input_type, PortType::Float | PortType::Int | PortType::Point) + { + return true; + } + // Everything can be converted to a string if matches!(input_type, PortType::String) { return true; diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-gui/src/node_library.rs index cc0eb7c8..0752dc29 100644 --- a/crates/nodebox-gui/src/node_library.rs +++ b/crates/nodebox-gui/src/node_library.rs @@ -1861,7 +1861,7 @@ pub fn create_node_from_template(template: &NodeTemplate, library: &NodeLibrary, node = node .with_input(Port::new("list", PortType::Data)) .with_input(Port::string("key", "x")) - .with_output_type(PortType::String); + .with_output_type(PortType::Data); } "filter_data" => { node = node From fea4c378447315f5c7694e9ee37189cfbccd9701 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 11:02:03 +0000 Subject: [PATCH 092/100] Add draggable splitter between Parameters and Network panels. https://claude.ai/code/session_01F3aZek3agmbWSjt2LehCng --- crates/nodebox-gui/src/app.rs | 66 ++++++++++++++++++++++++++++++--- crates/nodebox-gui/src/theme.rs | 4 ++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 767c537d..8e0aa61c 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -59,6 +59,8 @@ pub struct NodeBoxApp { 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 { @@ -132,6 +134,7 @@ impl NodeBoxApp { recent_files, pending_connection: None, was_dragging: false, + right_panel_split: 0.35, } } @@ -217,6 +220,7 @@ impl NodeBoxApp { recent_files, pending_connection: None, was_dragging: false, + right_panel_split: 0.35, } } @@ -252,6 +256,7 @@ impl NodeBoxApp { recent_files: RecentFiles::new(), pending_connection: None, was_dragging: false, + right_panel_split: 0.35, } } @@ -288,6 +293,7 @@ impl NodeBoxApp { recent_files: RecentFiles::new(), pending_connection: None, was_dragging: false, + right_panel_split: 0.35, } } @@ -815,13 +821,22 @@ impl eframe::App for NodeBoxApp { 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; + + // Enforce minimum heights: each panel gets at least 80px + let min_panel_height = 80.0_f32; + let splitter_affordance = theme::SPLITTER_AFFORDANCE; + let usable_height = available.height() - splitter_affordance; + let min_ratio = min_panel_height / usable_height; + let max_ratio = 1.0 - min_ratio; + self.right_panel_split = self.right_panel_split.clamp(min_ratio, max_ratio); + + let params_height = usable_height * self.right_panel_split; + let splitter_y = available.min.y + params_height; // Top: Parameters pane let params_rect = Rect::from_min_size( available.min, - Vec2::new(available.width(), split_y), + Vec2::new(available.width(), params_height), ); ui.scope_builder(egui::UiBuilder::new().max_rect(params_rect), |ui| { @@ -830,9 +845,50 @@ impl eframe::App for NodeBoxApp { .show(ui, &mut self.state, self.port.as_ref(), &self.project_context); }); - // Bottom: Network pane (headers have their own borders) + // Horizontal splitter between Parameters and Network + let splitter_rect = Rect::from_min_size( + Pos2::new(available.min.x, splitter_y), + Vec2::new(available.width(), splitter_affordance), + ); + + 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 centered within the affordance zone + let line_y = splitter_rect.center().y; + let stroke_color = if is_active { + theme::ZINC_300 + } else if is_hovered { + theme::ZINC_700 + } else { + theme::ZINC_900 + }; + ui.painter().line_segment( + [ + Pos2::new(splitter_rect.left(), line_y), + Pos2::new(splitter_rect.right(), line_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_params_height = pointer_pos.y - available.min.y; + let new_ratio = new_params_height / usable_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, available.min.y + split_y), + Pos2::new(available.min.x, splitter_y + splitter_affordance), available.max, ); diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index e2171474..2b42dd8f 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -241,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 From 5485d040c15fed2397ca48a60dbb54fd4acd31cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 11:41:48 +0000 Subject: [PATCH 093/100] Make parameters/network splitter line thinner (1px). https://claude.ai/code/session_01F3aZek3agmbWSjt2LehCng --- crates/nodebox-gui/src/theme.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 2b42dd8f..d2656e93 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -242,7 +242,7 @@ 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; +pub const SPLITTER_THICKNESS: f32 = 1.0; /// Splitter interaction zone height (larger than visual for easy grabbing). pub const SPLITTER_AFFORDANCE: f32 = 8.0; From 6d0221a4dfd1b3e4146073e52d87eb6eb4423b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 11:48:15 +0000 Subject: [PATCH 094/100] Eliminate visible gap around splitter by overlapping interaction zone. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panels now abut edge-to-edge with the interaction zone overlapping both, so only the 2px line is visible — matching the vertical splitter. https://claude.ai/code/session_01F3aZek3agmbWSjt2LehCng --- crates/nodebox-gui/src/app.rs | 35 +++++++++++++++------------------ crates/nodebox-gui/src/theme.rs | 2 +- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index 8e0aa61c..cf939565 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -1,6 +1,6 @@ //! Main application state and update loop. -use eframe::egui::{self, Pos2, Rect, Vec2}; +use eframe::egui::{self, Pos2, Rect}; use nodebox_core::geometry::Point; use nodebox_port::{Port, ProjectContext}; use std::sync::Arc; @@ -824,19 +824,16 @@ impl eframe::App for NodeBoxApp { // Enforce minimum heights: each panel gets at least 80px let min_panel_height = 80.0_f32; - let splitter_affordance = theme::SPLITTER_AFFORDANCE; - let usable_height = available.height() - splitter_affordance; - let min_ratio = min_panel_height / usable_height; + 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 params_height = usable_height * self.right_panel_split; - let splitter_y = available.min.y + params_height; + let split_y = available.min.y + available.height() * self.right_panel_split; // Top: Parameters pane - let params_rect = Rect::from_min_size( + let params_rect = Rect::from_min_max( available.min, - Vec2::new(available.width(), params_height), + Pos2::new(available.max.x, split_y), ); ui.scope_builder(egui::UiBuilder::new().max_rect(params_rect), |ui| { @@ -845,10 +842,12 @@ impl eframe::App for NodeBoxApp { .show(ui, &mut self.state, self.port.as_ref(), &self.project_context); }); - // Horizontal splitter between Parameters and Network - let splitter_rect = Rect::from_min_size( - Pos2::new(available.min.x, splitter_y), - Vec2::new(available.width(), splitter_affordance), + // 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"); @@ -857,8 +856,7 @@ impl eframe::App for NodeBoxApp { let is_active = splitter_response.dragged(); let is_hovered = splitter_response.hovered(); - // Draw splitter line centered within the affordance zone - let line_y = splitter_rect.center().y; + // Draw splitter line at the boundary let stroke_color = if is_active { theme::ZINC_300 } else if is_hovered { @@ -868,8 +866,8 @@ impl eframe::App for NodeBoxApp { }; ui.painter().line_segment( [ - Pos2::new(splitter_rect.left(), line_y), - Pos2::new(splitter_rect.right(), line_y), + Pos2::new(available.min.x, split_y), + Pos2::new(available.max.x, split_y), ], egui::Stroke::new(theme::SPLITTER_THICKNESS, stroke_color), ); @@ -880,15 +878,14 @@ impl eframe::App for NodeBoxApp { if splitter_response.dragged() { if let Some(pointer_pos) = ui.ctx().pointer_latest_pos() { - let new_params_height = pointer_pos.y - available.min.y; - let new_ratio = new_params_height / usable_height; + 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, splitter_y + splitter_affordance), + Pos2::new(available.min.x, split_y), available.max, ); diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index d2656e93..2b42dd8f 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -242,7 +242,7 @@ 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 = 1.0; +pub const SPLITTER_THICKNESS: f32 = 2.0; /// Splitter interaction zone height (larger than visual for easy grabbing). pub const SPLITTER_AFFORDANCE: f32 = 8.0; From dcf8db1529c7e4ee6b8c09f6936375dfd167a255 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 12:00:03 +0000 Subject: [PATCH 095/100] Make Data port type universally compatible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data is a dynamic type (lookup can return float, string, color, etc. depending on the column). Treat it like List — compatible with any input or output port type, with runtime determining the actual value. https://claude.ai/code/session_01Ssbet1itX47TACaYWBM1Wo --- crates/nodebox-core/src/node/port.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/nodebox-core/src/node/port.rs b/crates/nodebox-core/src/node/port.rs index beb74852..0c78bf54 100644 --- a/crates/nodebox-core/src/node/port.rs +++ b/crates/nodebox-core/src/node/port.rs @@ -93,18 +93,14 @@ impl PortType { return true; } - // Data <-> List bidirectional (data nodes consume and produce lists of data rows) - if matches!(output_type, PortType::Data) && matches!(input_type, PortType::List) { - return true; - } - if matches!(output_type, PortType::List) && matches!(input_type, PortType::Data) { + // 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 output can connect to numeric inputs (e.g. lookup returns Float for numeric columns) - if matches!(output_type, PortType::Data) - && matches!(input_type, PortType::Float | PortType::Int | PortType::Point) - { + // Data inputs accept any output (data nodes consume lists of data rows) + if matches!(input_type, PortType::Data) { return true; } From 8222284742890c464dfc60b63a251b73c4c65d29 Mon Sep 17 00:00:00 2001 From: Frederik De Bleser Date: Sun, 15 Feb 2026 13:03:50 +0100 Subject: [PATCH 096/100] Tweak theming colors --- crates/nodebox-gui/src/app.rs | 4 ++-- crates/nodebox-gui/src/theme.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-gui/src/app.rs index cf939565..14957d50 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-gui/src/app.rs @@ -860,9 +860,9 @@ impl eframe::App for NodeBoxApp { let stroke_color = if is_active { theme::ZINC_300 } else if is_hovered { - theme::ZINC_700 + theme::ZINC_400 } else { - theme::ZINC_900 + theme::ZINC_600 }; ui.painter().line_segment( [ diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-gui/src/theme.rs index 2b42dd8f..568ee6c8 100644 --- a/crates/nodebox-gui/src/theme.rs +++ b/crates/nodebox-gui/src/theme.rs @@ -277,8 +277,8 @@ 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 = ZINC_700; -pub const PORT_VALUE_BACKGROUND: Color32 = ZINC_600; +pub const PORT_LABEL_BACKGROUND: Color32 = ZINC_800; +pub const PORT_VALUE_BACKGROUND: Color32 = ZINC_700; // Tab colors pub const SELECTED_TAB_BACKGROUND: Color32 = ZINC_600; From 35daae7ac9df8e2f754952d7a60e9594294788db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 15:09:05 +0000 Subject: [PATCH 097/100] Consolidate crates: merge ops/ndbx/svg/port into core, rename gui to desktop Merge nodebox-ops, nodebox-ndbx, nodebox-svg, and nodebox-port into nodebox-core as submodules (ops/, ndbx/, svg/, port.rs). Rename nodebox-gui to nodebox-desktop with DesktopPort living there. Delete nodebox-cli (unused). After consolidation the workspace has 3 crates: - nodebox-core: geometry, node graph, value types, operations, NDBX parsing, SVG rendering, and the Port trait (unimplemented) - nodebox-desktop: native GUI app with DesktopPort implementation - nodebox-python: optional Python bindings (unchanged) https://claude.ai/code/session_01Xqg7t9yGwYdU922uwPyDob --- AGENTS.md | 9 +- Cargo.lock | 75 +--- Cargo.toml | 16 +- STYLE_GUIDE.md | 2 +- crates/nodebox-cli/Cargo.toml | 18 - crates/nodebox-cli/src/main.rs | 407 ------------------ crates/nodebox-core/Cargo.toml | 17 +- .../examples/generate_svg.rs | 2 +- .../examples/ops_demo.rs | 2 +- .../examples/text_dots.rs | 2 +- crates/nodebox-core/src/lib.rs | 10 +- .../src => nodebox-core/src/ndbx}/error.rs | 0 .../lib.rs => nodebox-core/src/ndbx/mod.rs} | 14 +- .../src => nodebox-core/src/ndbx}/parser.rs | 12 +- .../src/ndbx}/serializer.rs | 14 +- .../src => nodebox-core/src/ndbx}/upgrades.rs | 4 +- .../src => nodebox-core/src/ops}/data.rs | 0 .../src => nodebox-core/src/ops}/filters.rs | 36 +- .../src/ops}/generators.rs | 24 +- .../src => nodebox-core/src/ops}/list.rs | 0 .../src => nodebox-core/src/ops}/math.rs | 2 +- .../lib.rs => nodebox-core/src/ops/mod.rs} | 2 +- .../src => nodebox-core/src/ops}/parallel.rs | 4 +- .../src => nodebox-core/src/ops}/string.rs | 0 .../src => nodebox-core/src/ops}/svg.rs | 4 +- .../src/lib.rs => nodebox-core/src/port.rs} | 361 +--------------- crates/nodebox-core/src/svg/mod.rs | 7 + .../src => nodebox-core/src/svg}/renderer.rs | 4 +- .../tests/golden_tests.rs | 2 +- .../tests/parse_examples.rs | 5 +- .../Cargo.toml | 23 +- .../src/address_bar.rs | 0 .../src/animation_bar.rs | 0 .../src/app.rs | 16 +- .../src/canvas.rs | 0 .../src/components.rs | 0 .../src/desktop_port.rs} | 2 +- .../src/eval.rs | 383 ++++++++-------- .../src/export.rs | 0 .../src/handles.rs | 0 .../src/history.rs | 0 .../src/icon_cache.rs | 0 .../src/lib.rs | 13 +- .../src/native_menu.rs | 0 .../src/network_view.rs | 0 .../src/node_library.rs | 0 .../src/node_selection_dialog.rs | 0 .../src/notification_banner.rs | 0 .../src/pan_zoom.rs | 0 .../src/parameter_panel.rs | 2 +- .../src/recent_files.rs | 0 .../src/render_worker.rs | 2 +- .../src/state.rs | 8 +- .../src/theme.rs | 0 .../src/timeline.rs | 0 .../src/vello_convert.rs | 0 .../src/vello_renderer.rs | 0 .../src/vello_viewer.rs | 0 .../src/viewer_pane.rs | 2 +- .../tests/cancellation_tests.rs | 6 +- .../tests/common/mod.rs | 2 +- .../tests/file_tests.rs | 16 +- .../tests/handle_tests.rs | 12 +- .../tests/history_tests.rs | 2 +- .../tests/integration_tests.rs | 2 +- crates/nodebox-ndbx/Cargo.toml | 16 - crates/nodebox-ops/Cargo.toml | 23 - crates/nodebox-port/Cargo.toml | 42 -- crates/nodebox-python/Cargo.toml | 1 - crates/nodebox-python/src/operations.rs | 24 +- crates/nodebox-svg/Cargo.toml | 14 - crates/nodebox-svg/src/lib.rs | 18 - docs/async_nodes.md | 8 +- docs/plans/wgpu-rendering-plan.md | 16 +- docs/rust-translation-plan.md | 79 ++-- src/main.rs | 2 +- 76 files changed, 445 insertions(+), 1344 deletions(-) delete mode 100644 crates/nodebox-cli/Cargo.toml delete mode 100644 crates/nodebox-cli/src/main.rs rename crates/{nodebox-svg => nodebox-core}/examples/generate_svg.rs (99%) rename crates/{nodebox-ops => nodebox-core}/examples/ops_demo.rs (99%) rename crates/{nodebox-svg => nodebox-core}/examples/text_dots.rs (98%) rename crates/{nodebox-ndbx/src => nodebox-core/src/ndbx}/error.rs (100%) rename crates/{nodebox-ndbx/src/lib.rs => nodebox-core/src/ndbx/mod.rs} (51%) rename crates/{nodebox-ndbx/src => nodebox-core/src/ndbx}/parser.rs (98%) rename crates/{nodebox-ndbx/src => nodebox-core/src/ndbx}/serializer.rs (98%) rename crates/{nodebox-ndbx/src => nodebox-core/src/ndbx}/upgrades.rs (98%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/data.rs (100%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/filters.rs (98%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/generators.rs (97%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/list.rs (100%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/math.rs (99%) rename crates/{nodebox-ops/src/lib.rs => nodebox-core/src/ops/mod.rs} (91%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/parallel.rs (99%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/string.rs (100%) rename crates/{nodebox-ops/src => nodebox-core/src/ops}/svg.rs (99%) rename crates/{nodebox-port/src/lib.rs => nodebox-core/src/port.rs} (65%) create mode 100644 crates/nodebox-core/src/svg/mod.rs rename crates/{nodebox-svg/src => nodebox-core/src/svg}/renderer.rs (99%) rename crates/{nodebox-ops => nodebox-core}/tests/golden_tests.rs (99%) rename crates/{nodebox-ndbx => nodebox-core}/tests/parse_examples.rs (94%) rename crates/{nodebox-gui => nodebox-desktop}/Cargo.toml (69%) rename crates/{nodebox-gui => nodebox-desktop}/src/address_bar.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/animation_bar.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/app.rs (99%) rename crates/{nodebox-gui => nodebox-desktop}/src/canvas.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/components.rs (100%) rename crates/{nodebox-port/src/desktop.rs => nodebox-desktop/src/desktop_port.rs} (99%) rename crates/{nodebox-gui => nodebox-desktop}/src/eval.rs (91%) rename crates/{nodebox-gui => nodebox-desktop}/src/export.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/handles.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/history.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/icon_cache.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/lib.rs (87%) rename crates/{nodebox-gui => nodebox-desktop}/src/native_menu.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/network_view.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/node_library.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/node_selection_dialog.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/notification_banner.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/pan_zoom.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/parameter_panel.rs (99%) rename crates/{nodebox-gui => nodebox-desktop}/src/recent_files.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/render_worker.rs (99%) rename crates/{nodebox-gui => nodebox-desktop}/src/state.rs (99%) rename crates/{nodebox-gui => nodebox-desktop}/src/theme.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/timeline.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/vello_convert.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/vello_renderer.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/vello_viewer.rs (100%) rename crates/{nodebox-gui => nodebox-desktop}/src/viewer_pane.rs (99%) rename crates/{nodebox-gui => nodebox-desktop}/tests/cancellation_tests.rs (97%) rename crates/{nodebox-gui => nodebox-desktop}/tests/common/mod.rs (99%) rename crates/{nodebox-gui => nodebox-desktop}/tests/file_tests.rs (96%) rename crates/{nodebox-gui => nodebox-desktop}/tests/handle_tests.rs (97%) rename crates/{nodebox-gui => nodebox-desktop}/tests/history_tests.rs (99%) rename crates/{nodebox-gui => nodebox-desktop}/tests/integration_tests.rs (99%) delete mode 100644 crates/nodebox-ndbx/Cargo.toml delete mode 100644 crates/nodebox-ops/Cargo.toml delete mode 100644 crates/nodebox-port/Cargo.toml delete mode 100644 crates/nodebox-svg/Cargo.toml delete mode 100644 crates/nodebox-svg/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index ef3dce15..7b28fd61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,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 @@ -114,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::{ @@ -297,6 +297,5 @@ cargo test --workspace --exclude nodebox-python ### Running the application ```bash cargo run # Run the desktop GUI application -cargo run -p nodebox-cli # Run the CLI cargo test -p nodebox-core # Test specific crate ``` diff --git a/Cargo.lock b/Cargo.lock index 791a1ea5..bfeb1b7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2957,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]] @@ -2975,16 +2965,24 @@ 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", @@ -2992,58 +2990,20 @@ dependencies = [ "egui_extras", "egui_kittest", "env_logger", + "font-kit", "image", "log", "muda", "nodebox-core", - "nodebox-ndbx", - "nodebox-ops", - "nodebox-port", - "nodebox-svg", "pollster", + "rfd", "serde", "serde_json", "smol", "tempfile", "tiny-skia", - "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", - "csv", - "nodebox-core", - "proptest", - "rayon", - "tempfile", - "usvg", -] - -[[package]] -name = "nodebox-port" -version = "0.1.0" -dependencies = [ - "arboard", - "directories", - "font-kit", - "log", - "rfd", - "tempfile", - "thiserror 1.0.69", "ureq", + "vello", ] [[package]] @@ -3051,19 +3011,10 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 70a2d74f..48b85ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ name = "NodeBox" path = "src/main.rs" [dependencies] -nodebox-gui = { path = "crates/nodebox-gui" } +nodebox-desktop = { path = "crates/nodebox-desktop" } eframe = "0.33" [workspace] @@ -17,12 +17,7 @@ resolver = "2" members = [ ".", "crates/nodebox-core", - "crates/nodebox-ndbx", - "crates/nodebox-ops", - "crates/nodebox-svg", - "crates/nodebox-cli", - "crates/nodebox-gui", - "crates/nodebox-port", + "crates/nodebox-desktop", "crates/nodebox-python", ] @@ -31,12 +26,7 @@ members = [ default-members = [ ".", "crates/nodebox-core", - "crates/nodebox-ndbx", - "crates/nodebox-ops", - "crates/nodebox-svg", - "crates/nodebox-cli", - "crates/nodebox-gui", - "crates/nodebox-port", + "crates/nodebox-desktop", ] [workspace.package] diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index db34d9ee..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` --- 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 a721149b..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,20 @@ 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 @@ -19,3 +33,4 @@ harness = false [dev-dependencies] proptest = { workspace = true } approx = { workspace = true } +tempfile = "3" 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/lib.rs b/crates/nodebox-core/src/lib.rs index 1689f631..d9c1a342 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 (Port trait) pub mod geometry; pub mod node; pub mod value; +pub mod ops; +pub mod ndbx; +pub mod svg; +pub mod port; // 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-ndbx/src/lib.rs b/crates/nodebox-core/src/ndbx/mod.rs similarity index 51% rename from crates/nodebox-ndbx/src/lib.rs rename to crates/nodebox-core/src/ndbx/mod.rs index 0db1c5f3..9408a630 100644 --- a/crates/nodebox-ndbx/src/lib.rs +++ b/crates/nodebox-core/src/ndbx/mod.rs @@ -1,19 +1,7 @@ //! NDBX file format parser and serializer for NodeBox. //! -//! This crate parses `.ndbx` files (XML-based) into NodeBox's internal +//! Parses `.ndbx` files (XML-based) into NodeBox's internal //! node graph representation, and serializes them back to XML. -//! -//! # Example -//! -//! ```no_run -//! use nodebox_ndbx::{parse_file, serialize_to_file}; -//! -//! let library = parse_file("examples/my_project.ndbx").unwrap(); -//! println!("Loaded library: {}", library.name); -//! -//! // Save the library back to a file -//! serialize_to_file(&library, "output.ndbx").unwrap(); -//! ``` mod error; mod parser; diff --git a/crates/nodebox-ndbx/src/parser.rs b/crates/nodebox-core/src/ndbx/parser.rs similarity index 98% rename from crates/nodebox-ndbx/src/parser.rs rename to crates/nodebox-core/src/ndbx/parser.rs index 40d2e924..30ec6b16 100644 --- a/crates/nodebox-ndbx/src/parser.rs +++ b/crates/nodebox-core/src/ndbx/parser.rs @@ -6,12 +6,12 @@ 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::node::{Connection, MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; +use crate::geometry::Point; +use crate::Value; -use crate::error::{NdbxError, Result}; -use crate::upgrades::upgrade; +use super::error::{NdbxError, Result}; +use super::upgrades::upgrade; /// Parses an NDBX file from the given path, returning the library and any warnings. /// @@ -490,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-ndbx/src/serializer.rs b/crates/nodebox-core/src/ndbx/serializer.rs similarity index 98% rename from crates/nodebox-ndbx/src/serializer.rs rename to crates/nodebox-core/src/ndbx/serializer.rs index 6a7315c5..c1c8ce73 100644 --- a/crates/nodebox-ndbx/src/serializer.rs +++ b/crates/nodebox-core/src/ndbx/serializer.rs @@ -6,11 +6,11 @@ use std::fs; use std::io::Write; use std::path::Path; -use nodebox_core::node::{Connection, MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; -use nodebox_core::Value; +use crate::node::{Connection, MenuItem, Node, NodeLibrary, Port, PortRange, PortType, Widget}; +use crate::Value; -use crate::error::Result; -use crate::upgrades::CURRENT_FORMAT_VERSION; +use super::error::Result; +use super::upgrades::CURRENT_FORMAT_VERSION; /// Serializes a NodeLibrary to an XML string. pub fn serialize(library: &NodeLibrary) -> String { @@ -300,8 +300,8 @@ fn escape_xml(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use nodebox_core::geometry::{Color, Point}; - use nodebox_core::node::Connection; + use crate::geometry::{Color, Point}; + use crate::node::Connection; #[test] fn test_serialize_empty_library() { @@ -457,7 +457,7 @@ mod tests { #[test] fn test_round_trip() { - use crate::parse; + use crate::ndbx::parse; // Create a library with various node types and properties let mut original = NodeLibrary::new("test"); diff --git a/crates/nodebox-ndbx/src/upgrades.rs b/crates/nodebox-core/src/ndbx/upgrades.rs similarity index 98% rename from crates/nodebox-ndbx/src/upgrades.rs rename to crates/nodebox-core/src/ndbx/upgrades.rs index 400c24e6..83ed96a4 100644 --- a/crates/nodebox-ndbx/src/upgrades.rs +++ b/crates/nodebox-core/src/ndbx/upgrades.rs @@ -3,9 +3,9 @@ //! This module handles upgrading older .ndbx file formats to the current version. //! Old versions (< 21) are loaded best-effort with a warning. -use nodebox_core::node::NodeLibrary; +use crate::node::NodeLibrary; -use crate::error::NdbxError; +use super::error::NdbxError; /// The current format version used when saving .ndbx files. pub const CURRENT_FORMAT_VERSION: u32 = 22; diff --git a/crates/nodebox-ops/src/data.rs b/crates/nodebox-core/src/ops/data.rs similarity index 100% rename from crates/nodebox-ops/src/data.rs rename to crates/nodebox-core/src/ops/data.rs diff --git a/crates/nodebox-ops/src/filters.rs b/crates/nodebox-core/src/ops/filters.rs similarity index 98% rename from crates/nodebox-ops/src/filters.rs rename to crates/nodebox-core/src/ops/filters.rs index 29e93147..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, Rect}; +use crate::geometry::{Point, Path, Geometry, Color, Transform, Rect}; /// Horizontal alignment options. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -113,7 +113,7 @@ impl VDistribute { /// # 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); @@ -164,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); @@ -193,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); @@ -229,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); @@ -327,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)); @@ -395,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); @@ -418,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); @@ -438,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); @@ -461,7 +461,7 @@ 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); @@ -483,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); @@ -506,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)); @@ -526,7 +526,7 @@ 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); @@ -558,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); @@ -576,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); @@ -594,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); @@ -616,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); @@ -637,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); diff --git a/crates/nodebox-ops/src/generators.rs b/crates/nodebox-core/src/ops/generators.rs similarity index 97% rename from crates/nodebox-ops/src/generators.rs rename to crates/nodebox-core/src/ops/generators.rs index fc410947..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()); @@ -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 91% rename from crates/nodebox-ops/src/lib.rs rename to crates/nodebox-core/src/ops/mod.rs index 3774fce2..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 diff --git a/crates/nodebox-ops/src/parallel.rs b/crates/nodebox-core/src/ops/parallel.rs similarity index 99% rename from crates/nodebox-ops/src/parallel.rs rename to crates/nodebox-core/src/ops/parallel.rs index f055ec30..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. 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-ops/src/svg.rs b/crates/nodebox-core/src/ops/svg.rs similarity index 99% rename from crates/nodebox-ops/src/svg.rs rename to crates/nodebox-core/src/ops/svg.rs index 0644c041..50ed283c 100644 --- a/crates/nodebox-ops/src/svg.rs +++ b/crates/nodebox-core/src/ops/svg.rs @@ -6,7 +6,7 @@ //! SVG files through the Port system (for sandboxed file access) and passing //! the string content here. -use nodebox_core::geometry::{Color, Contour, Geometry, Path, Point, Transform}; +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. @@ -27,7 +27,7 @@ use usvg::{tiny_skia_path::PathSegment, Tree}; /// /// ```ignore /// use nodebox_core::geometry::Point; -/// use nodebox_ops::import_svg; +/// use nodebox_core::ops::import_svg; /// /// let svg_content = r#""#; /// let geometry = import_svg(svg_content, true, Point::ZERO)?; diff --git a/crates/nodebox-port/src/lib.rs b/crates/nodebox-core/src/port.rs similarity index 65% rename from crates/nodebox-port/src/lib.rs rename to crates/nodebox-core/src/port.rs index 8ee58446..7a635e87 100644 --- a/crates/nodebox-port/src/lib.rs +++ b/crates/nodebox-core/src/port.rs @@ -12,29 +12,6 @@ //! 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 -//! -//! # Example -//! -//! ```no_run -//! use nodebox_port::{Port, ProjectContext, RelativePath, DesktopPort}; -//! use std::path::PathBuf; -//! -//! let port = DesktopPort::new(); -//! let ctx = ProjectContext::new("/path/to/project", "project.ndbx"); -//! -//! // Read a file relative to project root using the convenience method -//! let svg_content = port.read_text_file(&ctx, "assets/logo.svg"); -//! -//! // Or use the lower-level API with RelativePath -//! let path = RelativePath::new("assets/image.png").unwrap(); -//! let data = port.read_file(&ctx, &path); -//! ``` - -#[cfg(not(target_arch = "wasm32"))] -mod desktop; - -#[cfg(not(target_arch = "wasm32"))] -pub use desktop::DesktopPort; use std::path::{Path, PathBuf}; use thiserror::Error; @@ -411,33 +388,9 @@ pub trait Port: Send + Sync { // === File Operations === /// Read a file from the project directory. - /// - /// # Arguments - /// - /// * `ctx` - Project context containing the root directory - /// * `path` - Path relative to project root - /// - /// # Errors - /// - /// * `PortError::NotFound` - File does not exist - /// * `PortError::PermissionDenied` - No permission to read file - /// * `PortError::IoError` - Other I/O error fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PortError>; /// Write a file to the project directory. - /// - /// Creates parent directories if they don't exist. - /// - /// # Arguments - /// - /// * `ctx` - Project context containing the root directory - /// * `path` - Path relative to project root - /// * `data` - Data to write - /// - /// # Errors - /// - /// * `PortError::PermissionDenied` - No permission to write file - /// * `PortError::IoError` - Other I/O error fn write_file( &self, ctx: &ProjectContext, @@ -446,17 +399,6 @@ pub trait Port: Send + Sync { ) -> Result<(), PortError>; /// List contents of a directory within the project. - /// - /// # Arguments - /// - /// * `ctx` - Project context containing the root directory - /// * `path` - Path relative to project root - /// - /// # Errors - /// - /// * `PortError::NotFound` - Directory does not exist - /// * `PortError::PermissionDenied` - No permission to read directory - /// * `PortError::IoError` - Other I/O error fn list_directory( &self, ctx: &ProjectContext, @@ -466,152 +408,41 @@ pub trait Port: Send + Sync { // === Convenience File Operations === /// Read a text file (UTF-8) from the project directory. - /// - /// This is a convenience method that validates the path is relative and within - /// the project sandbox, then reads the file as UTF-8 text. - /// - /// # Arguments - /// - /// * `ctx` - Project context containing the root directory - /// * `path` - Path string relative to project root - /// - /// # Errors - /// - /// * `PortError::Unsupported` - Project has no root directory (unsaved project) - /// * `PortError::SandboxViolation` - Path contains "..", is absolute, etc. - /// * `PortError::NotFound` - File does not exist - /// * `PortError::IoError` - Other I/O error or invalid UTF-8 fn read_text_file(&self, ctx: &ProjectContext, path: &str) -> Result; /// Read a binary file from the project directory. - /// - /// This is a convenience method that validates the path is relative and within - /// the project sandbox, then reads the file as binary data. - /// - /// # Arguments - /// - /// * `ctx` - Project context containing the root directory - /// * `path` - Path string relative to project root - /// - /// # Errors - /// - /// * `PortError::Unsupported` - Project has no root directory (unsaved project) - /// * `PortError::SandboxViolation` - Path contains "..", is absolute, etc. - /// * `PortError::NotFound` - File does not exist - /// * `PortError::IoError` - Other I/O error fn read_binary_file(&self, ctx: &ProjectContext, path: &str) -> Result, PortError>; /// Load an application resource (icons, fonts, etc.) - /// - /// These are bundled with the application, not project-specific. - /// Resources are located relative to the application executable or bundle. - /// - /// # Arguments - /// - /// * `name` - Resource path relative to resources directory (e.g., "icons/add.png") - /// - /// # Errors - /// - /// * `PortError::NotFound` - Resource does not exist - /// * `PortError::IoError` - Other I/O error fn load_app_resource(&self, name: &str) -> Result, PortError>; // === Project File (special handling) === /// Read the project file. - /// - /// # Arguments - /// - /// * `ctx` - Project context containing the root directory and project file name - /// - /// # Errors - /// - /// * `PortError::NotFound` - Project file does not exist - /// * `PortError::IoError` - Other I/O error fn read_project(&self, ctx: &ProjectContext) -> Result, PortError>; /// Write the project file. - /// - /// # Arguments - /// - /// * `ctx` - Project context containing the root directory and project file name - /// * `data` - Project data to write - /// - /// # Errors - /// - /// * `PortError::PermissionDenied` - No permission to write file - /// * `PortError::IoError` - Other I/O error fn write_project(&self, ctx: &ProjectContext, data: &[u8]) -> Result<(), PortError>; // === Library Access === /// Load a library by name. - /// - /// Libraries are located in platform-specific directories: - /// - macOS: `~/Library/Application Support/net.nodebox.NodeBox/libraries/` - /// - Windows: `%APPDATA%\NodeBox\libraries\` - /// - Linux: `~/.local/share/nodebox/libraries/` - /// - /// # Arguments - /// - /// * `name` - Name of the library (without extension) - /// - /// # Errors - /// - /// * `PortError::LibraryNotFound` - Library does not exist - /// * `PortError::IoError` - Other I/O error fn load_library(&self, name: &str) -> Result, PortError>; // === Network === /// Perform an HTTP GET request. - /// - /// # Arguments - /// - /// * `url` - URL to fetch - /// - /// # Errors - /// - /// * `PortError::NetworkError` - Network request failed - /// * `PortError::Unsupported` - Network not available on this platform fn http_get(&self, url: &str) -> Result, PortError>; // === Dialogs (Project-level, return absolute paths) === /// Show dialog to open a project file (no sandbox restriction). - /// - /// This is for opening .ndbx project files and returns an absolute path. - /// Use this when the user wants to open an existing project. - /// - /// # Arguments - /// - /// * `filters` - File type filters to show - /// - /// # Returns - /// - /// * `Ok(Some(path))` - User selected a file - /// * `Ok(None)` - User cancelled - /// * `Err(PortError::Unsupported)` - Dialogs not available fn show_open_project_dialog( &self, filters: &[FileFilter], ) -> Result, PortError>; /// Show dialog to choose where to save a new project. - /// - /// This is for "Save As" operations and returns an absolute path. - /// Use this to establish the project location for new/unsaved projects. - /// - /// # Arguments - /// - /// * `filters` - File type filters to show - /// * `default_name` - Optional default filename - /// - /// # Returns - /// - /// * `Ok(Some(path))` - User selected a save location - /// * `Ok(None)` - User cancelled - /// * `Err(PortError::Unsupported)` - Dialogs not available fn show_save_project_dialog( &self, filters: &[FileFilter], @@ -621,21 +452,6 @@ pub trait Port: Send + Sync { // === Dialogs (Asset-level, sandboxed to project) === /// Show "Open File" dialog for importing assets. - /// - /// The selected file must be within the project directory. If the user - /// selects a file outside the project, returns `Err(PortError::SandboxViolation)`. - /// - /// # Arguments - /// - /// * `ctx` - Project context (required for sandbox validation) - /// * `filters` - File type filters to show - /// - /// # Returns - /// - /// * `Ok(Some(path))` - User selected a valid file within project - /// * `Ok(None)` - User cancelled - /// * `Err(PortError::SandboxViolation)` - Selected file is outside project directory - /// * `Err(PortError::Unsupported)` - Dialogs not available fn show_open_file_dialog( &self, ctx: &ProjectContext, @@ -643,22 +459,6 @@ pub trait Port: Send + Sync { ) -> Result, PortError>; /// Show "Save File" dialog for exporting assets. - /// - /// The selected location must be within the project directory. If the user - /// selects a location outside the project, returns `Err(PortError::SandboxViolation)`. - /// - /// # Arguments - /// - /// * `ctx` - Project context (required for sandbox validation) - /// * `filters` - File type filters to show - /// * `default_name` - Optional default filename - /// - /// # Returns - /// - /// * `Ok(Some(path))` - User selected a valid location within project - /// * `Ok(None)` - User cancelled - /// * `Err(PortError::SandboxViolation)` - Selected location is outside project directory - /// * `Err(PortError::Unsupported)` - Dialogs not available fn show_save_file_dialog( &self, ctx: &ProjectContext, @@ -667,52 +467,15 @@ pub trait Port: Send + Sync { ) -> Result, PortError>; /// Show a "Select Folder" dialog for selecting a directory within the project. - /// - /// The selected folder must be within the project directory. If the user - /// selects a folder outside the project, returns `Err(PortError::SandboxViolation)`. - /// - /// # Arguments - /// - /// * `ctx` - Project context (required for sandbox validation) - /// - /// # Returns - /// - /// * `Ok(Some(path))` - User selected a valid folder within project - /// * `Ok(None)` - User cancelled - /// * `Err(PortError::SandboxViolation)` - Selected folder is outside project directory - /// * `Err(PortError::Unsupported)` - Dialogs not available fn show_select_folder_dialog( &self, ctx: &ProjectContext, ) -> Result, PortError>; /// Show a confirmation dialog with OK and Cancel buttons. - /// - /// # Arguments - /// - /// * `title` - Dialog title - /// * `message` - Dialog message - /// - /// # Returns - /// - /// * `Ok(true)` - User clicked OK - /// * `Ok(false)` - User clicked Cancel - /// * `Err(PortError::Unsupported)` - Dialogs not available fn show_confirm_dialog(&self, title: &str, message: &str) -> Result; /// Show a message dialog with custom buttons. - /// - /// # Arguments - /// - /// * `title` - Dialog title - /// * `message` - Dialog message - /// * `buttons` - Button labels (2-3 buttons) - /// - /// # Returns - /// - /// * `Ok(Some(index))` - User clicked button at index (0-based) - /// * `Ok(None)` - User dismissed without selection - /// * `Err(PortError::Unsupported)` - Dialogs not available fn show_message_dialog( &self, title: &str, @@ -723,63 +486,27 @@ pub trait Port: Send + Sync { // === Clipboard === /// Read text from the clipboard. - /// - /// # Returns - /// - /// * `Ok(Some(text))` - Clipboard contains text - /// * `Ok(None)` - Clipboard is empty or doesn't contain text - /// * `Err(PortError::Unsupported)` - Clipboard not available fn clipboard_read_text(&self) -> Result, PortError>; /// Write text to the clipboard. - /// - /// # Arguments - /// - /// * `text` - Text to write to clipboard - /// - /// # Errors - /// - /// * `Err(PortError::Unsupported)` - Clipboard not available - /// * `Err(PortError::Other)` - Failed to write to clipboard fn clipboard_write_text(&self, text: &str) -> Result<(), PortError>; // === Logging === /// Log a message at the specified level. - /// - /// Messages are routed to the appropriate destination: - /// - Desktop: stderr or log file - /// - Web: console.log/console.error - /// - Mobile: NSLog/android.util.Log fn log(&self, level: LogLevel, message: &str); // === Performance === /// Create a performance mark. - /// - /// Used for profiling and performance analysis. fn performance_mark(&self, name: &str); /// Create a performance mark with additional details. - /// - /// # Arguments - /// - /// * `name` - Mark name - /// * `details` - Additional details (JSON string) fn performance_mark_with_details(&self, name: &str, details: &str); // === Configuration === /// Get the configuration directory for storing app settings. - /// - /// Platform-specific locations: - /// - macOS: `~/Library/Application Support/net.nodebox.NodeBox/` - /// - Windows: `%APPDATA%\NodeBox\` - /// - Linux: `~/.config/nodebox/` - /// - /// # Errors - /// - /// * `Err(PortError::Unsupported)` - No config directory on this platform fn get_config_dir(&self) -> Result; /// List available font families on the system. @@ -946,7 +673,6 @@ mod tests { #[test] fn test_relative_path_valid() { - // Simple paths should be 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()); @@ -955,64 +681,30 @@ mod tests { #[test] fn test_relative_path_rejects_parent_dir() { - // Paths with ".." should be rejected - assert!(matches!( - RelativePath::new(".."), - Err(PortError::SandboxViolation) - )); - assert!(matches!( - RelativePath::new("../file.txt"), - Err(PortError::SandboxViolation) - )); - assert!(matches!( - RelativePath::new("subdir/../other.txt"), - Err(PortError::SandboxViolation) - )); - assert!(matches!( - RelativePath::new("a/b/../../c.txt"), - Err(PortError::SandboxViolation) - )); + assert!(matches!(RelativePath::new(".."), Err(PortError::SandboxViolation))); + assert!(matches!(RelativePath::new("../file.txt"), Err(PortError::SandboxViolation))); + assert!(matches!(RelativePath::new("subdir/../other.txt"), Err(PortError::SandboxViolation))); + assert!(matches!(RelativePath::new("a/b/../../c.txt"), Err(PortError::SandboxViolation))); } #[test] fn test_relative_path_rejects_absolute() { - // Absolute paths should be rejected - assert!(matches!( - RelativePath::new("/etc/passwd"), - Err(PortError::SandboxViolation) - )); - assert!(matches!( - RelativePath::new("/home/user/file.txt"), - Err(PortError::SandboxViolation) - )); + assert!(matches!(RelativePath::new("/etc/passwd"), Err(PortError::SandboxViolation))); + assert!(matches!(RelativePath::new("/home/user/file.txt"), Err(PortError::SandboxViolation))); } #[test] fn test_relative_path_rejects_windows_absolute() { - // Windows-style absolute paths should be rejected - assert!(matches!( - RelativePath::new("C:/Users/file.txt"), - Err(PortError::SandboxViolation) - )); - assert!(matches!( - RelativePath::new("D:\\Documents\\file.txt"), - Err(PortError::SandboxViolation) - )); + assert!(matches!(RelativePath::new("C:/Users/file.txt"), Err(PortError::SandboxViolation))); + assert!(matches!(RelativePath::new("D:\\Documents\\file.txt"), Err(PortError::SandboxViolation))); } #[test] fn test_relative_path_join() { let base = RelativePath::new("subdir").unwrap(); - - // Valid joins let joined = base.join("file.txt").unwrap(); assert_eq!(joined.as_path(), Path::new("subdir/file.txt")); - - // Invalid joins (with ..) - assert!(matches!( - base.join("../escape.txt"), - Err(PortError::SandboxViolation) - )); + assert!(matches!(base.join("../escape.txt"), Err(PortError::SandboxViolation))); } #[test] @@ -1027,10 +719,7 @@ mod tests { 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")) - ); + assert_eq!(ctx.project_path(), Some(PathBuf::from("/home/user/project/myproject.ndbx"))); } #[test] @@ -1047,7 +736,6 @@ mod tests { 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); @@ -1058,7 +746,6 @@ mod tests { 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"]); } @@ -1075,43 +762,25 @@ mod tests { fn test_port_error_from_io_error() { let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "not found"); assert!(matches!(PortError::from(not_found), PortError::NotFound)); - let permission = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); - assert!(matches!( - PortError::from(permission), - PortError::PermissionDenied - )); - + assert!(matches!(PortError::from(permission), PortError::PermissionDenied)); let other = std::io::Error::new(std::io::ErrorKind::Other, "something else"); assert!(matches!(PortError::from(other), PortError::IoError(_))); } #[test] fn test_port_error_display() { - assert_eq!( - format!("{}", PortError::Unsupported), - "operation not supported on this platform" - ); + assert_eq!(format!("{}", PortError::Unsupported), "operation not supported on this platform"); assert_eq!(format!("{}", PortError::NotFound), "not found"); assert_eq!(format!("{}", PortError::PermissionDenied), "permission denied"); - assert_eq!( - format!("{}", PortError::SandboxViolation), - "path escapes project sandbox" - ); - assert_eq!( - format!("{}", PortError::NetworkError("timeout".to_string())), - "network error: timeout" - ); - assert_eq!( - format!("{}", PortError::LibraryNotFound("math".to_string())), - "library not found: math" - ); + assert_eq!(format!("{}", PortError::SandboxViolation), "path escapes project sandbox"); + assert_eq!(format!("{}", PortError::NetworkError("timeout".to_string())), "network error: timeout"); + assert_eq!(format!("{}", PortError::LibraryNotFound("math".to_string())), "library not found: math"); } #[test] fn test_platform_info_current() { let info = PlatformInfo::current(); - // Just verify it doesn't panic and returns something reasonable 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 99% rename from crates/nodebox-svg/src/renderer.rs rename to crates/nodebox-core/src/svg/renderer.rs index d2d60291..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); diff --git a/crates/nodebox-ops/tests/golden_tests.rs b/crates/nodebox-core/tests/golden_tests.rs similarity index 99% rename from crates/nodebox-ops/tests/golden_tests.rs rename to crates/nodebox-core/tests/golden_tests.rs index 4c87fb30..ee48602b 100644 --- a/crates/nodebox-ops/tests/golden_tests.rs +++ b/crates/nodebox-core/tests/golden_tests.rs @@ -4,7 +4,7 @@ //! They serve as regression tests to catch unintended changes in behavior. use nodebox_core::geometry::{Color, Path, Point}; -use nodebox_ops::*; +use nodebox_core::ops::*; // ============================================================================ // Helper Functions diff --git a/crates/nodebox-ndbx/tests/parse_examples.rs b/crates/nodebox-core/tests/parse_examples.rs similarity index 94% rename from crates/nodebox-ndbx/tests/parse_examples.rs rename to crates/nodebox-core/tests/parse_examples.rs index 4e30f594..7e590272 100644 --- a/crates/nodebox-ndbx/tests/parse_examples.rs +++ b/crates/nodebox-core/tests/parse_examples.rs @@ -1,6 +1,6 @@ //! Integration tests for parsing actual NDBX files. -use nodebox_ndbx::{parse_file, parse_file_with_warnings}; +use nodebox_core::ndbx::{parse_file, parse_file_with_warnings}; use std::path::Path; /// Test that parsing old format versions (< 21) succeeds best-effort with warnings. @@ -74,8 +74,7 @@ fn test_parse_old_demo_file_loads_with_warning() { } // This file has an old/missing format version but should still load best-effort - let (library, warnings) = parse_file_with_warnings(&path) + 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"); - // May or may not have warnings depending on the file's version } diff --git a/crates/nodebox-gui/Cargo.toml b/crates/nodebox-desktop/Cargo.toml similarity index 69% rename from crates/nodebox-gui/Cargo.toml rename to crates/nodebox-desktop/Cargo.toml index 476b937a..08e0ec72 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,15 +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-port = { path = "../nodebox-port" } -nodebox-svg = { path = "../nodebox-svg" } # GUI eframe = "0.33" @@ -44,6 +40,19 @@ smol = "2" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.15" +# DesktopPort 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-gui/src/address_bar.rs b/crates/nodebox-desktop/src/address_bar.rs similarity index 100% rename from crates/nodebox-gui/src/address_bar.rs rename to crates/nodebox-desktop/src/address_bar.rs diff --git a/crates/nodebox-gui/src/animation_bar.rs b/crates/nodebox-desktop/src/animation_bar.rs similarity index 100% rename from crates/nodebox-gui/src/animation_bar.rs rename to crates/nodebox-desktop/src/animation_bar.rs diff --git a/crates/nodebox-gui/src/app.rs b/crates/nodebox-desktop/src/app.rs similarity index 99% rename from crates/nodebox-gui/src/app.rs rename to crates/nodebox-desktop/src/app.rs index 14957d50..51d13678 100644 --- a/crates/nodebox-gui/src/app.rs +++ b/crates/nodebox-desktop/src/app.rs @@ -2,7 +2,7 @@ use eframe::egui::{self, Pos2, Rect}; use nodebox_core::geometry::Point; -use nodebox_port::{Port, ProjectContext}; +use nodebox_core::port::{Port, ProjectContext}; use std::sync::Arc; use crate::address_bar::{AddressBar, AddressBarAction}; @@ -161,7 +161,7 @@ impl NodeBoxApp { // Create a default DesktopPort for backwards compatibility #[cfg(not(target_arch = "wasm32"))] - let port: Arc = Arc::new(nodebox_port::DesktopPort::new()); + let port: Arc = Arc::new(crate::DesktopPort::new()); #[cfg(target_arch = "wasm32")] compile_error!("WASM builds must use new_with_port with a custom Port implementation"); @@ -235,7 +235,7 @@ impl NodeBoxApp { let hash = Self::hash_library(&state.library); let prev_library = Arc::clone(&state.library); Self { - port: Arc::new(nodebox_port::DesktopPort::new()), + port: Arc::new(crate::DesktopPort::new()), project_context: ProjectContext::new_unsaved(), state, address_bar: AddressBar::new(), @@ -272,7 +272,7 @@ impl NodeBoxApp { let hash = Self::hash_library(&state.library); let prev_library = Arc::clone(&state.library); Self { - port: Arc::new(nodebox_port::DesktopPort::new()), + port: Arc::new(crate::DesktopPort::new()), project_context: ProjectContext::new_unsaved(), state, address_bar: AddressBar::new(), @@ -1185,7 +1185,7 @@ impl NodeBoxApp { } fn open_file(&mut self) { - use nodebox_port::FileFilter; + use nodebox_core::port::FileFilter; match self.port.show_open_project_dialog(&[FileFilter::nodebox()]) { Ok(Some(path)) => { @@ -1248,7 +1248,7 @@ impl NodeBoxApp { } fn save_file_as(&mut self) { - use nodebox_port::FileFilter; + use nodebox_core::port::FileFilter; match self.port.show_save_project_dialog(&[FileFilter::nodebox()], Some("untitled.ndbx")) { Ok(Some(path)) => { @@ -1269,7 +1269,7 @@ impl NodeBoxApp { } fn export_svg(&mut self) { - use nodebox_port::FileFilter; + use nodebox_core::port::FileFilter; // Export dialogs use save_project_dialog since exports are not sandboxed match self.port.show_save_project_dialog(&[FileFilter::svg()], Some("export.svg")) { @@ -1287,7 +1287,7 @@ impl NodeBoxApp { } fn export_png(&mut self) { - use nodebox_port::FileFilter; + use nodebox_core::port::FileFilter; // Export dialogs use save_project_dialog since exports are not sandboxed match self.port.show_save_project_dialog(&[FileFilter::png()], Some("export.png")) { diff --git a/crates/nodebox-gui/src/canvas.rs b/crates/nodebox-desktop/src/canvas.rs similarity index 100% rename from crates/nodebox-gui/src/canvas.rs rename to crates/nodebox-desktop/src/canvas.rs diff --git a/crates/nodebox-gui/src/components.rs b/crates/nodebox-desktop/src/components.rs similarity index 100% rename from crates/nodebox-gui/src/components.rs rename to crates/nodebox-desktop/src/components.rs diff --git a/crates/nodebox-port/src/desktop.rs b/crates/nodebox-desktop/src/desktop_port.rs similarity index 99% rename from crates/nodebox-port/src/desktop.rs rename to crates/nodebox-desktop/src/desktop_port.rs index ab4e344e..921eb918 100644 --- a/crates/nodebox-port/src/desktop.rs +++ b/crates/nodebox-desktop/src/desktop_port.rs @@ -1,6 +1,6 @@ //! Desktop (macOS, Windows, Linux) implementation of the Port trait. -use crate::{ +use nodebox_core::port::{ DirectoryEntry, FileFilter, LogLevel, PlatformInfo, Port, PortError, ProjectContext, RelativePath, }; diff --git a/crates/nodebox-gui/src/eval.rs b/crates/nodebox-desktop/src/eval.rs similarity index 91% rename from crates/nodebox-gui/src/eval.rs rename to crates/nodebox-desktop/src/eval.rs index 3bbdc46d..8ab508ae 100644 --- a/crates/nodebox-gui/src/eval.rs +++ b/crates/nodebox-desktop/src/eval.rs @@ -7,8 +7,9 @@ use nodebox_core::geometry::font; use nodebox_core::node::{Node, NodeLibrary, EvalError}; use nodebox_core::node::PortRange; use nodebox_core::Value; -use nodebox_port::{Port, ProjectContext}; -use nodebox_ops::data::DataValue; +use nodebox_core::port::{Port, ProjectContext}; +use nodebox_core::ops; +use ops::data::DataValue; use crate::render_worker::CancellationToken; /// Error information for a specific node. @@ -1030,7 +1031,7 @@ fn execute_node( 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); + let path = ops::ellipse(position, width, height); Ok(NodeOutput::Path(path)) } "corevector.rect" => { @@ -1039,14 +1040,14 @@ fn execute_node( 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); + 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 = nodebox_ops::line(p1, p2, points); + let path = ops::line(p1, p2, points); Ok(NodeOutput::Path(path)) } "corevector.polygon" => { @@ -1054,7 +1055,7 @@ fn execute_node( 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 = nodebox_ops::polygon(position, radius, sides, align); + let path = ops::polygon(position, radius, sides, align); Ok(NodeOutput::Path(path)) } "corevector.star" => { @@ -1062,7 +1063,7 @@ fn execute_node( 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 = nodebox_ops::star(position, points, outer, inner); + let path = ops::star(position, points, outer, inner); Ok(NodeOutput::Path(path)) } "corevector.arc" => { @@ -1073,7 +1074,7 @@ fn execute_node( 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); + let path = ops::arc(position, width, height, start_angle, degrees, &arc_type); Ok(NodeOutput::Path(path)) } "corevector.textpath" => { @@ -1092,27 +1093,27 @@ fn execute_node( 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); + 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 = nodebox_ops::translate(&shape, offset); + 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 = nodebox_ops::rotate(&shape, angle, origin); + 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 = nodebox_ops::scale(&shape, scale, origin); + let path = ops::scale(&shape, scale, origin); Ok(NodeOutput::Path(path)) } "corevector.align" => { @@ -1120,7 +1121,7 @@ fn execute_node( 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); + let path = ops::align_str(&shape, position, &halign, &valign); Ok(NodeOutput::Path(path)) } "corevector.fit" => { @@ -1130,18 +1131,18 @@ fn execute_node( 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); + 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 = nodebox_ops::CopyOrder::from_str(&get_string(inputs, "order", "tsr")); + 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 = nodebox_ops::copy(&shape, copies, order, translate, rotate, scale); + let paths = ops::copy(&shape, copies, order, translate, rotate, scale); Ok(NodeOutput::Paths(paths)) } @@ -1181,10 +1182,10 @@ fn execute_node( let method = get_string(inputs, "method", "length"); let path = if method == "length" { let length = get_float(inputs, "length", 10.0).max(1.0); - nodebox_ops::resample_by_length(&shape, length) + ops::resample_by_length(&shape, length) } else { let points = get_int(inputs, "points", 20).max(0) as usize; - nodebox_ops::resample(&shape, points) + ops::resample(&shape, points) }; Ok(NodeOutput::Path(path)) } @@ -1192,11 +1193,11 @@ fn execute_node( // Wiggle "corevector.wiggle" => { let shape = require_path(inputs, node_name, "shape")?; - let scope = nodebox_ops::WiggleScope::from_str(&get_string(inputs, "scope", "points")); + 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 = nodebox_ops::wiggle(&shape, scope, offset, seed); + let path = ops::wiggle(&shape, scope, offset, seed); Ok(NodeOutput::Path(path)) } @@ -1206,7 +1207,7 @@ fn execute_node( let closed = get_bool(inputs, "closed", false); match inputs.get("points") { Some(NodeOutput::Points(pts)) => { - let path = nodebox_ops::connect(pts, closed); + let path = ops::connect(pts, closed); Ok(NodeOutput::Path(path)) } _ => Ok(NodeOutput::None), @@ -1221,7 +1222,7 @@ fn execute_node( 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); + let points = ops::grid(columns, rows, width, height, position); Ok(NodeOutput::Points(points)) } @@ -1239,8 +1240,8 @@ fn execute_node( 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); - Ok(NodeOutput::Paths(nodebox_ops::ungroup(&geometry))) + let geometry = ops::reflect(&shape, position, angle, keep_original); + Ok(NodeOutput::Paths(ops::ungroup(&geometry))) } // Skew @@ -1249,7 +1250,7 @@ fn execute_node( // 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); + let path = ops::skew(&shape, skew, origin); Ok(NodeOutput::Path(path)) } @@ -1260,7 +1261,7 @@ fn execute_node( 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); + let path = ops::snap(&shape, distance, strength, position); Ok(NodeOutput::Path(path)) } @@ -1270,14 +1271,14 @@ fn execute_node( 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); + 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 = nodebox_ops::centroid(&shape); + let point = ops::centroid(&shape); Ok(NodeOutput::Point(point)) } @@ -1287,7 +1288,7 @@ fn execute_node( 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 = nodebox_ops::line_angle(position, angle, distance, points); + let path = ops::line_angle(position, angle, distance, points); Ok(NodeOutput::Path(path)) } @@ -1297,7 +1298,7 @@ fn execute_node( 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); + let path = ops::quad_curve(point1, point2, t, distance); Ok(NodeOutput::Path(path)) } @@ -1306,7 +1307,7 @@ fn execute_node( 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 = nodebox_ops::scatter(&shape, amount, seed); + let points = ops::scatter(&shape, amount, seed); Ok(NodeOutput::Points(points)) } @@ -1316,12 +1317,12 @@ fn execute_node( 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, + "north" => ops::StackDirection::North, + "south" => ops::StackDirection::South, + "west" => ops::StackDirection::West, + _ => ops::StackDirection::East, }; - let paths = nodebox_ops::stack(&shapes, dir, margin); + let paths = ops::stack(&shapes, dir, margin); Ok(NodeOutput::Paths(paths)) } @@ -1330,16 +1331,16 @@ fn execute_node( let shapes = require_paths(inputs, node_name, "shapes")?; let horizontal = get_string(inputs, "horizontal", "none"); let vertical = get_string(inputs, "vertical", "none"); - let h = nodebox_ops::HDistribute::from_str(&horizontal); - let v = nodebox_ops::VDistribute::from_str(&vertical); - let paths = nodebox_ops::distribute(&shapes, h, v); + 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 = nodebox_ops::freehand(&path_string); + let path = ops::freehand(&path_string); Ok(NodeOutput::Path(path)) } @@ -1349,15 +1350,15 @@ fn execute_node( let shape2 = require_path(inputs, node_name, "shape2")?; let orientation = get_string(inputs, "orientation", "horizontal"); let horizontal = orientation == "horizontal"; - let path = nodebox_ops::link(&shape1, &shape2, horizontal); + let path = ops::link(&shape1, &shape2, horizontal); Ok(NodeOutput::Path(path)) } // Group "corevector.group" => { let shapes = get_paths(inputs, "shapes"); - let geometry = nodebox_ops::group(&shapes); - Ok(NodeOutput::Paths(nodebox_ops::ungroup(&geometry))) + let geometry = ops::group(&shapes); + Ok(NodeOutput::Paths(ops::ungroup(&geometry))) } // Ungroup @@ -1376,7 +1377,7 @@ fn execute_node( 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 = nodebox_ops::fit_to(&shape, &bounding, keep_proportions); + let path = ops::fit_to(&shape, &bounding, keep_proportions); Ok(NodeOutput::Path(path)) } @@ -1389,12 +1390,12 @@ fn execute_node( }; let scope = get_string(inputs, "scope", "points"); let delete_scope = match scope.as_str() { - "paths" => nodebox_ops::DeleteScope::Paths, - _ => nodebox_ops::DeleteScope::Points, + "paths" => ops::DeleteScope::Paths, + _ => 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); + let path = ops::delete(&shape, &bounding, delete_scope, delete_inside); Ok(NodeOutput::Path(path)) } @@ -1403,13 +1404,13 @@ fn execute_node( 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" => nodebox_ops::SortBy::Y, - "distance" => nodebox_ops::SortBy::Distance, - "angle" => nodebox_ops::SortBy::Angle, - _ => nodebox_ops::SortBy::X, + "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 = nodebox_ops::sort_paths(&shapes, sort_by, position); + let paths = ops::sort_paths(&shapes, sort_by, position); Ok(NodeOutput::Paths(paths)) } @@ -1434,7 +1435,7 @@ fn execute_node( }; // Parse the SVG content - match nodebox_ops::import_svg(&svg_content, centered, position) { + match ops::import_svg(&svg_content, centered, position) { Ok(geometry) => { if geometry.is_empty() { Ok(NodeOutput::None) @@ -1458,7 +1459,7 @@ fn execute_node( 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 = nodebox_ops::shape_on_path(&shapes, &path, amount, spacing, margin, true); + let paths = ops::shape_on_path(&shapes, &path, amount, spacing, margin, true); Ok(NodeOutput::Paths(paths)) } @@ -1490,17 +1491,17 @@ fn execute_node( "math.add" => { let v1 = get_float(inputs, "value1", 0.0); let v2 = get_float(inputs, "value2", 0.0); - Ok(NodeOutput::Float(nodebox_ops::math::add(v1, v2))) + 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(nodebox_ops::math::subtract(v1, v2))) + 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(nodebox_ops::math::multiply(v1, v2))) + Ok(NodeOutput::Float(ops::math::multiply(v1, v2))) } "math.divide" => { let v1 = get_float(inputs, "value1", 0.0); @@ -1508,7 +1509,7 @@ fn execute_node( if v2 == 0.0 { return Err(EvalError::ProcessingError(format!("{}: Division by zero", node_name))); } - Ok(NodeOutput::Float(nodebox_ops::math::divide(v1, v2))) + Ok(NodeOutput::Float(ops::math::divide(v1, v2))) } "math.mod" => { let v1 = get_float(inputs, "value1", 0.0); @@ -1516,71 +1517,71 @@ fn execute_node( if v2 == 0.0 { return Err(EvalError::ProcessingError(format!("{}: Modulo by zero", node_name))); } - Ok(NodeOutput::Float(nodebox_ops::math::modulo(v1, v2))) + Ok(NodeOutput::Float(ops::math::modulo(v1, v2))) } // Unary math "math.negate" => { - Ok(NodeOutput::Float(nodebox_ops::math::negate(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Float(ops::math::negate(get_float(inputs, "value", 0.0)))) } "math.abs" => { - Ok(NodeOutput::Float(nodebox_ops::math::abs(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Float(ops::math::abs(get_float(inputs, "value", 0.0)))) } "math.sqrt" => { - Ok(NodeOutput::Float(nodebox_ops::math::sqrt(get_float(inputs, "value", 0.0)))) + 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(nodebox_ops::math::pow(v1, v2))) + 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(nodebox_ops::math::log(v))) + Ok(NodeOutput::Float(ops::math::log(v))) } // Rounding "math.ceil" => { - Ok(NodeOutput::Float(nodebox_ops::math::ceil(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Float(ops::math::ceil(get_float(inputs, "value", 0.0)))) } "math.floor" => { - Ok(NodeOutput::Float(nodebox_ops::math::floor(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Float(ops::math::floor(get_float(inputs, "value", 0.0)))) } "math.round" => { - Ok(NodeOutput::Int(nodebox_ops::math::round(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Int(ops::math::round(get_float(inputs, "value", 0.0)))) } // Trigonometry "math.sin" => { - Ok(NodeOutput::Float(nodebox_ops::math::sin(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Float(ops::math::sin(get_float(inputs, "value", 0.0)))) } "math.cos" => { - Ok(NodeOutput::Float(nodebox_ops::math::cos(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Float(ops::math::cos(get_float(inputs, "value", 0.0)))) } "math.radians" => { - Ok(NodeOutput::Float(nodebox_ops::math::radians(get_float(inputs, "degrees", 0.0)))) + Ok(NodeOutput::Float(ops::math::radians(get_float(inputs, "degrees", 0.0)))) } "math.degrees" => { - Ok(NodeOutput::Float(nodebox_ops::math::degrees(get_float(inputs, "radians", 0.0)))) + Ok(NodeOutput::Float(ops::math::degrees(get_float(inputs, "radians", 0.0)))) } // Constants "math.pi" => { - Ok(NodeOutput::Float(nodebox_ops::math::pi())) + Ok(NodeOutput::Float(ops::math::pi())) } "math.e" => { - Ok(NodeOutput::Float(nodebox_ops::math::e())) + Ok(NodeOutput::Float(ops::math::e())) } // Predicates "math.even" => { - Ok(NodeOutput::Boolean(nodebox_ops::math::even(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Boolean(ops::math::even(get_float(inputs, "value", 0.0)))) } "math.odd" => { - Ok(NodeOutput::Boolean(nodebox_ops::math::odd(get_float(inputs, "value", 0.0)))) + Ok(NodeOutput::Boolean(ops::math::odd(get_float(inputs, "value", 0.0)))) } // Comparison / logic @@ -1588,56 +1589,56 @@ fn execute_node( 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(nodebox_ops::math::compare(v1, v2, &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(nodebox_ops::math::logic_operator(b1, b2, &comparator))) + 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(nodebox_ops::math::angle(p1, p2))) + 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(nodebox_ops::math::distance(p1, p2))) + 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(nodebox_ops::math::coordinates(position, angle, distance))) + 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(nodebox_ops::math::reflect(p1, p2, angle, distance))) + Ok(NodeOutput::Point(ops::math::reflect(p1, p2, angle, distance))) } // Aggregation "math.sum" => { let values = get_floats(inputs, "values"); - Ok(NodeOutput::Float(nodebox_ops::math::sum(&values))) + Ok(NodeOutput::Float(ops::math::sum(&values))) } "math.average" => { let values = get_floats(inputs, "values"); - Ok(NodeOutput::Float(nodebox_ops::math::average(&values))) + Ok(NodeOutput::Float(ops::math::average(&values))) } "math.max" => { let values = get_floats(inputs, "values"); - Ok(NodeOutput::Float(nodebox_ops::math::max(&values))) + Ok(NodeOutput::Float(ops::math::max(&values))) } "math.min" => { let values = get_floats(inputs, "values"); - Ok(NodeOutput::Float(nodebox_ops::math::min(&values))) + Ok(NodeOutput::Float(ops::math::min(&values))) } // Convert range @@ -1648,8 +1649,8 @@ fn execute_node( 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 = nodebox_ops::math::OverflowMethod::from_str(&method); - Ok(NodeOutput::Float(nodebox_ops::math::convert_range( + 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, ))) } @@ -1661,38 +1662,38 @@ fn execute_node( 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 = nodebox_ops::math::WaveType::from_str(&wave_type_str); - Ok(NodeOutput::Float(nodebox_ops::math::wave(min, max, period, offset, wave_type))) + 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(nodebox_ops::math::make_numbers(&s, &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(nodebox_ops::math::random_numbers(amount, start, end, seed))) + 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(nodebox_ops::math::sample(amount, start, end))) + 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(nodebox_ops::math::range(start, end, step))) + Ok(NodeOutput::Floats(ops::math::range(start, end, step))) } "math.running_total" => { let values = get_floats(inputs, "values"); - Ok(NodeOutput::Floats(nodebox_ops::math::running_total(&values))) + Ok(NodeOutput::Floats(ops::math::running_total(&values))) } // ======================== @@ -1704,11 +1705,11 @@ fn execute_node( } "string.length" => { let s = get_string(inputs, "string", ""); - Ok(NodeOutput::Int(nodebox_ops::string::length(&s) as i64)) + Ok(NodeOutput::Int(ops::string::length(&s) as i64)) } "string.word_count" => { let s = get_string(inputs, "string", ""); - Ok(NodeOutput::Int(nodebox_ops::string::word_count(&s) as i64)) + Ok(NodeOutput::Int(ops::string::word_count(&s) as i64)) } "string.concatenate" => { let s1 = get_string(inputs, "string1", ""); @@ -1720,96 +1721,96 @@ fn execute_node( 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(nodebox_ops::string::concatenate(&parts))) + 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 = nodebox_ops::string::CaseMethod::from_str(&method); - Ok(NodeOutput::String(nodebox_ops::string::change_case(&s, case_method))) + 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(nodebox_ops::string::format_number(value, &format))) + Ok(NodeOutput::String(ops::string::format_number(value, &format))) } "string.trim" => { let s = get_string(inputs, "string", ""); - Ok(NodeOutput::String(nodebox_ops::string::trim(&s))) + 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(nodebox_ops::string::replace(&s, &old_val, &new_val))) + 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(nodebox_ops::string::sub_string(&s, start, end, end_offset))) + 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(nodebox_ops::string::character_at(&s, index))) + 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(nodebox_ops::string::as_binary_string(&s, &digit_sep, &byte_sep))) + 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(nodebox_ops::string::contains(&s, &value))) + 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(nodebox_ops::string::ends_with(&s, &value))) + 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(nodebox_ops::string::starts_with(&s, &value))) + 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(nodebox_ops::string::equal(&s, &value, case_sensitive))) + 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(nodebox_ops::string::make_strings(&s, &separator))) + Ok(NodeOutput::Strings(ops::string::make_strings(&s, &separator))) } "string.characters" => { let s = get_string(inputs, "string", ""); - Ok(NodeOutput::Strings(nodebox_ops::string::characters(&s))) + 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(nodebox_ops::string::random_character(&chars, amount, seed))) + Ok(NodeOutput::Strings(ops::string::random_character(&chars, amount, seed))) } "string.as_binary_list" => { let s = get_string(inputs, "string", ""); - Ok(NodeOutput::Strings(nodebox_ops::string::as_binary_list(&s))) + 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(nodebox_ops::string::as_number_list(&s, radix, padding))) + Ok(NodeOutput::Strings(ops::string::as_number_list(&s, radix, padding))) } // ======================== @@ -1870,26 +1871,26 @@ fn execute_node( "list.rest" => { match inputs.get("list") { - Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(nodebox_ops::list::rest(v))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::rest(v))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::rest(v))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::rest(v))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::rest(v))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::rest(v))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::rest(v))), + 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(nodebox_ops::list::reverse(v))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::reverse(v))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::reverse(v))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::reverse(v))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::reverse(v))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::reverse(v))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::reverse(v))), + 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), } } @@ -1899,13 +1900,13 @@ fn execute_node( 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(nodebox_ops::list::slice(v, start_index, size, invert))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::slice(v, start_index, size, invert))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::slice(v, start_index, size, invert))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::slice(v, start_index, size, invert))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::slice(v, start_index, size, invert))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::slice(v, start_index, size, invert))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::slice(v, start_index, size, invert))), + 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), } } @@ -1913,13 +1914,13 @@ fn execute_node( "list.shift" => { let amount = get_int(inputs, "amount", 1); match inputs.get("list") { - Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(nodebox_ops::list::shift(v, amount))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::shift(v, amount))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::shift(v, amount))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::shift(v, amount))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::shift(v, amount))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::shift(v, amount))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::shift(v, amount))), + 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), } } @@ -1928,22 +1929,22 @@ fn execute_node( 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(nodebox_ops::list::repeat(v, amount, per_item))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::repeat(v, amount, per_item))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::repeat(v, amount, per_item))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::repeat(v, amount, per_item))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::repeat(v, amount, per_item))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::repeat(v, amount, per_item))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::repeat(v, amount, per_item))), + 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(nodebox_ops::list::sort_floats(v))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::sort(v))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::sort(v))), + 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), } @@ -1952,13 +1953,13 @@ fn execute_node( "list.shuffle" => { let seed = get_int(inputs, "seed", 0) as u64; match inputs.get("list") { - Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(nodebox_ops::list::shuffle(v, seed))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::shuffle(v, seed))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::shuffle(v, seed))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::shuffle(v, seed))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::shuffle(v, seed))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::shuffle(v, seed))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::shuffle(v, seed))), + 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), } } @@ -1967,13 +1968,13 @@ fn execute_node( 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(nodebox_ops::list::pick(v, amount, seed))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::pick(v, amount, seed))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::pick(v, amount, seed))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::pick(v, amount, seed))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::pick(v, amount, seed))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::pick(v, amount, seed))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::pick(v, amount, seed))), + 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), } } @@ -1981,13 +1982,13 @@ fn execute_node( "list.cull" => { let booleans = get_booleans(inputs, "booleans"); match inputs.get("list") { - Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(nodebox_ops::list::cull(v, &booleans))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::cull(v, &booleans))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::cull(v, &booleans))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::cull(v, &booleans))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::cull(v, &booleans))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::cull(v, &booleans))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::cull(v, &booleans))), + 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), } } @@ -1995,23 +1996,23 @@ fn execute_node( "list.take_every" => { let n = get_int(inputs, "n", 1) as usize; match inputs.get("list") { - Some(NodeOutput::Paths(v)) => Ok(NodeOutput::Paths(nodebox_ops::list::take_every(v, n))), - Some(NodeOutput::Points(v)) => Ok(NodeOutput::Points(nodebox_ops::list::take_every(v, n))), - Some(NodeOutput::Floats(v)) => Ok(NodeOutput::Floats(nodebox_ops::list::take_every(v, n))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::take_every(v, n))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::take_every(v, n))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::take_every(v, n))), - Some(NodeOutput::Colors(v)) => Ok(NodeOutput::Colors(nodebox_ops::list::take_every(v, n))), + 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(nodebox_ops::list::distinct_floats(v))), - Some(NodeOutput::Ints(v)) => Ok(NodeOutput::Ints(nodebox_ops::list::distinct(v))), - Some(NodeOutput::Strings(v)) => Ok(NodeOutput::Strings(nodebox_ops::list::distinct(v))), - Some(NodeOutput::Booleans(v)) => Ok(NodeOutput::Booleans(nodebox_ops::list::distinct(v))), + 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), } @@ -2190,7 +2191,7 @@ fn execute_node( _ => b'"', }; let number_separator = get_string(inputs, "number_separator", "period"); - let rows = nodebox_ops::data::import_csv( + let rows = ops::data::import_csv( &content, delimiter, quote_char, &number_separator, ); Ok(NodeOutput::DataRows(rows)) @@ -2213,21 +2214,21 @@ fn execute_node( .map(|i| get_as_data_values(inputs, &format!("list{}", i))) .collect(); - let rows = nodebox_ops::data::make_table(&headers, &lists); + 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 nodebox_ops::data::lookup(row, &key) { + 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 nodebox_ops::data::lookup(&rows[0], &key) { + 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())), @@ -2241,7 +2242,7 @@ fn execute_node( let key = get_string(inputs, "key", "name"); let op = get_string(inputs, "op", "="); let value = get_string(inputs, "value", ""); - let filtered = nodebox_ops::data::filter_data(&rows, &key, &op, &value); + let filtered = ops::data::filter_data(&rows, &key, &op, &value); Ok(NodeOutput::DataRows(filtered)) } @@ -2270,10 +2271,10 @@ fn execute_node( mod tests { use super::*; use nodebox_core::node::{Port as NodePort, Connection, PortRange}; - use nodebox_port::{TestPort, ProjectContext}; + use nodebox_core::port::{TestPort, ProjectContext}; /// Create a test port and project context for evaluation tests. - fn test_port_and_context() -> (Arc, ProjectContext) { + fn test_port_and_context() -> (Arc, ProjectContext) { (Arc::new(TestPort::new()), ProjectContext::new_unsaved()) } diff --git a/crates/nodebox-gui/src/export.rs b/crates/nodebox-desktop/src/export.rs similarity index 100% rename from crates/nodebox-gui/src/export.rs rename to crates/nodebox-desktop/src/export.rs 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-gui/src/history.rs b/crates/nodebox-desktop/src/history.rs similarity index 100% rename from crates/nodebox-gui/src/history.rs rename to crates/nodebox-desktop/src/history.rs 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 87% rename from crates/nodebox-gui/src/lib.rs rename to crates/nodebox-desktop/src/lib.rs index f7ad3b4a..7c768ebd 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_port; +#[cfg(not(target_arch = "wasm32"))] +pub use desktop_port::DesktopPort; + mod address_bar; mod animation_bar; pub mod app; @@ -82,7 +87,7 @@ pub fn run() -> eframe::Result<()> { env_logger::init(); // Create the desktop port for file operations - let port: Arc = Arc::new(nodebox_port::DesktopPort::new()); + let port: Arc = Arc::new(crate::DesktopPort::new()); // Get initial file from command line arguments let initial_file: Option = std::env::args() diff --git a/crates/nodebox-gui/src/native_menu.rs b/crates/nodebox-desktop/src/native_menu.rs similarity index 100% rename from crates/nodebox-gui/src/native_menu.rs rename to crates/nodebox-desktop/src/native_menu.rs diff --git a/crates/nodebox-gui/src/network_view.rs b/crates/nodebox-desktop/src/network_view.rs similarity index 100% rename from crates/nodebox-gui/src/network_view.rs rename to crates/nodebox-desktop/src/network_view.rs diff --git a/crates/nodebox-gui/src/node_library.rs b/crates/nodebox-desktop/src/node_library.rs similarity index 100% rename from crates/nodebox-gui/src/node_library.rs rename to crates/nodebox-desktop/src/node_library.rs diff --git a/crates/nodebox-gui/src/node_selection_dialog.rs b/crates/nodebox-desktop/src/node_selection_dialog.rs similarity index 100% rename from crates/nodebox-gui/src/node_selection_dialog.rs rename to crates/nodebox-desktop/src/node_selection_dialog.rs diff --git a/crates/nodebox-gui/src/notification_banner.rs b/crates/nodebox-desktop/src/notification_banner.rs similarity index 100% rename from crates/nodebox-gui/src/notification_banner.rs rename to crates/nodebox-desktop/src/notification_banner.rs diff --git a/crates/nodebox-gui/src/pan_zoom.rs b/crates/nodebox-desktop/src/pan_zoom.rs similarity index 100% rename from crates/nodebox-gui/src/pan_zoom.rs rename to crates/nodebox-desktop/src/pan_zoom.rs diff --git a/crates/nodebox-gui/src/parameter_panel.rs b/crates/nodebox-desktop/src/parameter_panel.rs similarity index 99% rename from crates/nodebox-gui/src/parameter_panel.rs rename to crates/nodebox-desktop/src/parameter_panel.rs index e2bc2738..e7a97f77 100644 --- a/crates/nodebox-gui/src/parameter_panel.rs +++ b/crates/nodebox-desktop/src/parameter_panel.rs @@ -5,7 +5,7 @@ use eframe::egui::{self, Sense}; use nodebox_core::geometry::Color; use nodebox_core::node::{PortType, Widget}; use nodebox_core::Value; -use nodebox_port::{FileFilter, Port, PortError, ProjectContext}; +use nodebox_core::port::{FileFilter, Port, PortError, ProjectContext}; use crate::components; use crate::state::AppState; use crate::theme; diff --git a/crates/nodebox-gui/src/recent_files.rs b/crates/nodebox-desktop/src/recent_files.rs similarity index 100% rename from crates/nodebox-gui/src/recent_files.rs rename to crates/nodebox-desktop/src/recent_files.rs diff --git a/crates/nodebox-gui/src/render_worker.rs b/crates/nodebox-desktop/src/render_worker.rs similarity index 99% rename from crates/nodebox-gui/src/render_worker.rs rename to crates/nodebox-desktop/src/render_worker.rs index 2b8c7112..1add76c1 100644 --- a/crates/nodebox-gui/src/render_worker.rs +++ b/crates/nodebox-desktop/src/render_worker.rs @@ -7,7 +7,7 @@ use std::thread; use std::time::Instant; use nodebox_core::geometry::Path as GeoPath; use nodebox_core::node::NodeLibrary; -use nodebox_port::{Port, ProjectContext}; +use nodebox_core::port::{Port, ProjectContext}; use crate::eval::{NodeError, NodeOutput}; /// Token for cooperative cancellation of render operations. diff --git a/crates/nodebox-gui/src/state.rs b/crates/nodebox-desktop/src/state.rs similarity index 99% rename from crates/nodebox-gui/src/state.rs rename to crates/nodebox-desktop/src/state.rs index bc47665f..4ab320d2 100644 --- a/crates/nodebox-gui/src/state.rs +++ b/crates/nodebox-desktop/src/state.rs @@ -134,7 +134,7 @@ impl AppState { 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_ndbx::parse_file_with_warnings(path).map_err(|e| e.to_string())?; + 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); @@ -173,7 +173,7 @@ impl AppState { /// Save the current document. pub fn save_file(&mut self, path: &Path) -> Result<(), String> { - nodebox_ndbx::serialize_to_file(&self.library, path) + 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; @@ -183,10 +183,10 @@ impl AppState { /// 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) + let options = nodebox_core::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); + let svg = nodebox_core::svg::render_to_svg_with_options(&self.geometry, &options); std::fs::write(path, svg).map_err(|e| e.to_string()) } } diff --git a/crates/nodebox-gui/src/theme.rs b/crates/nodebox-desktop/src/theme.rs similarity index 100% rename from crates/nodebox-gui/src/theme.rs rename to crates/nodebox-desktop/src/theme.rs 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 100% rename from crates/nodebox-gui/src/vello_convert.rs rename to crates/nodebox-desktop/src/vello_convert.rs 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 100% rename from crates/nodebox-gui/src/vello_viewer.rs rename to crates/nodebox-desktop/src/vello_viewer.rs diff --git a/crates/nodebox-gui/src/viewer_pane.rs b/crates/nodebox-desktop/src/viewer_pane.rs similarity index 99% rename from crates/nodebox-gui/src/viewer_pane.rs rename to crates/nodebox-desktop/src/viewer_pane.rs index 3fb05be9..5d093e84 100644 --- a/crates/nodebox-gui/src/viewer_pane.rs +++ b/crates/nodebox-desktop/src/viewer_pane.rs @@ -4,7 +4,7 @@ use eframe::egui::{self, Color32, ColorImage, Pos2, Rect, Stroke, TextureHandle, use egui_extras::{Column, TableBuilder}; use nodebox_core::geometry::{Color, Path, PathPoint, Point, PointType}; use std::collections::HashMap; -use nodebox_ops::data::DataValue; +use nodebox_core::ops::data::DataValue; use crate::components; use crate::eval::NodeOutput; use crate::handles::{FourPointHandle, HandleSet, HANDLE_COLOR}; diff --git a/crates/nodebox-gui/tests/cancellation_tests.rs b/crates/nodebox-desktop/tests/cancellation_tests.rs similarity index 97% rename from crates/nodebox-gui/tests/cancellation_tests.rs rename to crates/nodebox-desktop/tests/cancellation_tests.rs index 80e7dd3b..3eb22955 100644 --- a/crates/nodebox-gui/tests/cancellation_tests.rs +++ b/crates/nodebox-desktop/tests/cancellation_tests.rs @@ -6,9 +6,9 @@ use std::thread; use std::time::{Duration, Instant}; use nodebox_core::geometry::Point; use nodebox_core::node::{Node, NodeLibrary, Port}; -use nodebox_gui::eval::{EvalOutcome, NodeOutput, evaluate_network_cancellable}; -use nodebox_gui::render_worker::CancellationToken; -use nodebox_port::{Port as PortTrait, ProjectContext, TestPort}; +use nodebox_desktop::eval::{EvalOutcome, NodeOutput, evaluate_network_cancellable}; +use nodebox_desktop::render_worker::CancellationToken; +use nodebox_core::port::{Port as PortTrait, ProjectContext, TestPort}; /// Create a test port and project context for evaluation tests. fn test_port_and_context() -> (Arc, ProjectContext) { 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-gui/tests/file_tests.rs b/crates/nodebox-desktop/tests/file_tests.rs similarity index 96% rename from crates/nodebox-gui/tests/file_tests.rs rename to crates/nodebox-desktop/tests/file_tests.rs index 0047b28a..fe8926c7 100644 --- a/crates/nodebox-gui/tests/file_tests.rs +++ b/crates/nodebox-desktop/tests/file_tests.rs @@ -9,9 +9,9 @@ use std::sync::Arc; use nodebox_core::geometry::{Color, Point}; use nodebox_core::node::{Connection, Node, NodeLibrary, Port}; -use nodebox_gui::eval::evaluate_network; -use nodebox_gui::{populate_default_ports, AppState}; -use nodebox_port::{Port as PortTrait, ProjectContext, TestPort}; +use nodebox_desktop::eval::evaluate_network; +use nodebox_desktop::{populate_default_ports, AppState}; +use nodebox_core::port::{Port as PortTrait, ProjectContext, TestPort}; /// Create a test port and project context for evaluation tests. fn test_port_and_context() -> (Arc, ProjectContext) { @@ -54,7 +54,7 @@ fn test_old_version_files_load_with_warning() { return; } - let result = nodebox_ndbx::parse_file_with_warnings(&path); + 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(); @@ -73,7 +73,7 @@ fn test_library_files_can_be_loaded() { return; } - let library = nodebox_ndbx::parse_file(&path).expect("Library files should load"); + 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); @@ -379,10 +379,10 @@ fn test_save_and_reload() { let original = create_primitives_library(); // Serialize to string - let xml = nodebox_ndbx::serialize(&original); + let xml = nodebox_core::ndbx::serialize(&original); // Parse back - let reloaded = nodebox_ndbx::parse(&xml).expect("Should be able to parse serialized content"); + let reloaded = nodebox_core::ndbx::parse(&xml).expect("Should be able to parse serialized content"); // Verify key properties assert_eq!(reloaded.format_version, 22); @@ -427,7 +427,7 @@ fn test_load_all_library_files() { .extension() .map_or(false, |e| e == "ndbx") { - match nodebox_ndbx::parse_file(file.path()) { + match nodebox_core::ndbx::parse_file(file.path()) { Ok(library) => { // Basic sanity check assert!(!library.root.name.is_empty()); diff --git a/crates/nodebox-gui/tests/handle_tests.rs b/crates/nodebox-desktop/tests/handle_tests.rs similarity index 97% rename from crates/nodebox-gui/tests/handle_tests.rs rename to crates/nodebox-desktop/tests/handle_tests.rs index 0572c52a..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); @@ -249,7 +249,7 @@ fn test_star_handle_reads_position_port() { #[test] fn test_primitives_handles_read_correct_positions() { - use nodebox_core::node::{Connection, NodeLibrary}; + use nodebox_core::node::NodeLibrary; // Create a library similar to the Primitives example let mut library = NodeLibrary::new("test"); @@ -277,7 +277,7 @@ fn test_primitives_handles_read_correct_positions() { .with_child(ellipse1) .with_child(polygon1); - nodebox_gui::populate_default_ports(&mut library.root); + nodebox_desktop::populate_default_ports(&mut library.root); // Get nodes let rect = library.root.child("rect1").expect("rect1 should exist"); diff --git a/crates/nodebox-gui/tests/history_tests.rs b/crates/nodebox-desktop/tests/history_tests.rs similarity index 99% rename from crates/nodebox-gui/tests/history_tests.rs rename to crates/nodebox-desktop/tests/history_tests.rs index 16343ca4..b4d2f16d 100644 --- a/crates/nodebox-gui/tests/history_tests.rs +++ b/crates/nodebox-desktop/tests/history_tests.rs @@ -3,7 +3,7 @@ mod common; use std::sync::Arc; -use nodebox_gui::{History, SelectionSnapshot, Node, NodeLibrary, Port}; +use nodebox_desktop::{History, SelectionSnapshot, Node, NodeLibrary, Port}; /// Create a simple test library with an ellipse. fn create_test_library(x: f64) -> Arc { 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-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-ops/Cargo.toml b/crates/nodebox-ops/Cargo.toml deleted file mode 100644 index 1a5647e9..00000000 --- a/crates/nodebox-ops/Cargo.toml +++ /dev/null @@ -1,23 +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" -usvg = "0.42" -csv = "1" - -[features] -default = [] -parallel = [] - -[dev-dependencies] -proptest = { workspace = true } -approx = { workspace = true } -tempfile = "3" diff --git a/crates/nodebox-port/Cargo.toml b/crates/nodebox-port/Cargo.toml deleted file mode 100644 index bfc9e5d4..00000000 --- a/crates/nodebox-port/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "nodebox-port" -description = "Platform abstraction layer for NodeBox" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true - -[lib] -name = "nodebox_port" -path = "src/lib.rs" - -[dependencies] -# Error handling -thiserror.workspace = true - -# Logging -log = "0.4" - -# File dialogs (desktop only) -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -rfd = "0.15" - -# Clipboard (desktop only) -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.arboard] -version = "3" - -# HTTP client (desktop only, blocking) -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.ureq] -version = "2" - -# Platform directories (desktop only) -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.directories] -version = "5" - -# Font enumeration (desktop only) -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.font-kit] -workspace = true - -[dev-dependencies] -tempfile = "3" 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/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 index 5b17d463..50e2085b 100644 --- a/docs/async_nodes.md +++ b/docs/async_nodes.md @@ -225,7 +225,7 @@ Keyboard shortcut: `Cmd+.` (macOS) or `Ctrl+.` (Windows/Linux) ## Related Files -- `crates/nodebox-gui/src/render_worker.rs` - CancellationToken, worker loop -- `crates/nodebox-gui/src/eval.rs` - evaluate_network_cancellable() -- `crates/nodebox-gui/src/address_bar.rs` - Stop button UI -- `crates/nodebox-gui/tests/cancellation_tests.rs` - Integration tests +- `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..65d2c93b 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 +│ │ │ ├── port.rs # Port 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::port`: Port 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/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() } From 25f23344af5a1c92ff97bd4fd34c85bb5d1a2c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 15:47:18 +0000 Subject: [PATCH 098/100] Rename platform abstraction Port to Platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Port` name was overloaded: it referred to both node connection ports and the platform abstraction trait. Rename the platform abstraction to `Platform` to disambiguate: - `Port` trait → `Platform` trait - `PortError` → `PlatformError` - `TestPort` → `TestPlatform` - `DesktopPort` → `DesktopPlatform` - `port.rs` → `platform.rs` - `desktop_port.rs` → `desktop_platform.rs` https://claude.ai/code/session_01MVWg5jAgGpHUSLpY2cjrKW --- crates/nodebox-core/src/lib.rs | 4 +- .../nodebox-core/src/{port.rs => platform.rs} | 204 ++++++++--------- crates/nodebox-desktop/Cargo.toml | 2 +- crates/nodebox-desktop/src/app.rs | 38 +-- .../{desktop_port.rs => desktop_platform.rs} | 216 +++++++++--------- crates/nodebox-desktop/src/eval.rs | 20 +- crates/nodebox-desktop/src/lib.rs | 11 +- crates/nodebox-desktop/src/parameter_panel.rs | 10 +- crates/nodebox-desktop/src/render_worker.rs | 10 +- .../tests/cancellation_tests.rs | 10 +- crates/nodebox-desktop/tests/file_tests.rs | 8 +- docs/rust-translation-plan.md | 4 +- docs/server-api.md | 28 +-- 13 files changed, 283 insertions(+), 282 deletions(-) rename crates/nodebox-core/src/{port.rs => platform.rs} (78%) rename crates/nodebox-desktop/src/{desktop_port.rs => desktop_platform.rs} (78%) diff --git a/crates/nodebox-core/src/lib.rs b/crates/nodebox-core/src/lib.rs index d9c1a342..a2f95045 100644 --- a/crates/nodebox-core/src/lib.rs +++ b/crates/nodebox-core/src/lib.rs @@ -7,7 +7,7 @@ //! - Geometry operations (generators, filters, math, etc.) //! - NDBX file format (parse/serialize) //! - SVG rendering -//! - Platform abstraction (Port trait) +//! - Platform abstraction (Platform trait) pub mod geometry; pub mod node; @@ -15,7 +15,7 @@ pub mod value; pub mod ops; pub mod ndbx; pub mod svg; -pub mod port; +pub mod platform; // Re-export commonly used types at the crate root pub use geometry::{ diff --git a/crates/nodebox-core/src/port.rs b/crates/nodebox-core/src/platform.rs similarity index 78% rename from crates/nodebox-core/src/port.rs rename to crates/nodebox-core/src/platform.rs index 7a635e87..e5f2a22f 100644 --- a/crates/nodebox-core/src/port.rs +++ b/crates/nodebox-core/src/platform.rs @@ -1,13 +1,13 @@ //! Platform abstraction layer for NodeBox. //! -//! The Port system provides a unified interface for platform-specific I/O operations, +//! 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 `Port` trait; -//! unsupported operations return `Err(PortError::Unsupported)` +//! 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, @@ -180,16 +180,16 @@ impl RelativePath { /// /// # Errors /// - /// Returns `PortError::SandboxViolation` if: + /// 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 { + pub fn new(path: impl AsRef) -> Result { let path = path.as_ref(); // Check for absolute paths if path.is_absolute() { - return Err(PortError::SandboxViolation); + return Err(PlatformError::SandboxViolation); } // Check for Windows-style absolute paths (C:\, D:\, etc.) @@ -197,7 +197,7 @@ impl RelativePath { if s.len() >= 2 { let chars: Vec = s.chars().take(2).collect(); if chars[0].is_ascii_alphabetic() && chars[1] == ':' { - return Err(PortError::SandboxViolation); + return Err(PlatformError::SandboxViolation); } } } @@ -205,7 +205,7 @@ impl RelativePath { // Check for ".." components that could escape the sandbox for component in path.components() { if let std::path::Component::ParentDir = component { - return Err(PortError::SandboxViolation); + return Err(PlatformError::SandboxViolation); } } @@ -223,8 +223,8 @@ impl RelativePath { /// /// # Errors /// - /// Returns `PortError::SandboxViolation` if the resulting path would escape the sandbox. - pub fn join(&self, path: impl AsRef) -> Result { + /// 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) } @@ -331,7 +331,7 @@ impl std::fmt::Display for LogLevel { /// Errors that can occur during Port operations. #[derive(Debug, Error)] -pub enum PortError { +pub enum PlatformError { /// Operation not supported on this platform #[error("operation not supported on this platform")] Unsupported, @@ -365,21 +365,21 @@ pub enum PortError { Other(String), } -impl From for PortError { +impl From for PlatformError { fn from(err: std::io::Error) -> Self { match err.kind() { - std::io::ErrorKind::NotFound => PortError::NotFound, - std::io::ErrorKind::PermissionDenied => PortError::PermissionDenied, - _ => PortError::IoError(err.to_string()), + std::io::ErrorKind::NotFound => PlatformError::NotFound, + std::io::ErrorKind::PermissionDenied => PlatformError::PermissionDenied, + _ => PlatformError::IoError(err.to_string()), } } } -/// The main Port trait for platform abstraction. +/// 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 Port: Send + Sync { +pub trait Platform: Send + Sync { // === Platform Info === /// Get information about the current platform. @@ -388,7 +388,7 @@ pub trait Port: Send + Sync { // === File Operations === /// Read a file from the project directory. - fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PortError>; + fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PlatformError>; /// Write a file to the project directory. fn write_file( @@ -396,43 +396,43 @@ pub trait Port: Send + Sync { ctx: &ProjectContext, path: &RelativePath, data: &[u8], - ) -> Result<(), PortError>; + ) -> Result<(), PlatformError>; /// List contents of a directory within the project. fn list_directory( &self, ctx: &ProjectContext, path: &RelativePath, - ) -> Result, PortError>; + ) -> 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; + 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, PortError>; + 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, PortError>; + fn load_app_resource(&self, name: &str) -> Result, PlatformError>; // === Project File (special handling) === /// Read the project file. - fn read_project(&self, ctx: &ProjectContext) -> Result, PortError>; + fn read_project(&self, ctx: &ProjectContext) -> Result, PlatformError>; /// Write the project file. - fn write_project(&self, ctx: &ProjectContext, data: &[u8]) -> Result<(), PortError>; + fn write_project(&self, ctx: &ProjectContext, data: &[u8]) -> Result<(), PlatformError>; // === Library Access === /// Load a library by name. - fn load_library(&self, name: &str) -> Result, PortError>; + fn load_library(&self, name: &str) -> Result, PlatformError>; // === Network === /// Perform an HTTP GET request. - fn http_get(&self, url: &str) -> Result, PortError>; + fn http_get(&self, url: &str) -> Result, PlatformError>; // === Dialogs (Project-level, return absolute paths) === @@ -440,14 +440,14 @@ pub trait Port: Send + Sync { fn show_open_project_dialog( &self, filters: &[FileFilter], - ) -> Result, PortError>; + ) -> 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, PortError>; + ) -> Result, PlatformError>; // === Dialogs (Asset-level, sandboxed to project) === @@ -456,7 +456,7 @@ pub trait Port: Send + Sync { &self, ctx: &ProjectContext, filters: &[FileFilter], - ) -> Result, PortError>; + ) -> Result, PlatformError>; /// Show "Save File" dialog for exporting assets. fn show_save_file_dialog( @@ -464,16 +464,16 @@ pub trait Port: Send + Sync { ctx: &ProjectContext, filters: &[FileFilter], default_name: Option<&str>, - ) -> Result, PortError>; + ) -> Result, PlatformError>; /// Show a "Select Folder" dialog for selecting a directory within the project. fn show_select_folder_dialog( &self, ctx: &ProjectContext, - ) -> Result, PortError>; + ) -> Result, PlatformError>; /// Show a confirmation dialog with OK and Cancel buttons. - fn show_confirm_dialog(&self, title: &str, message: &str) -> Result; + fn show_confirm_dialog(&self, title: &str, message: &str) -> Result; /// Show a message dialog with custom buttons. fn show_message_dialog( @@ -481,15 +481,15 @@ pub trait Port: Send + Sync { title: &str, message: &str, buttons: &[&str], - ) -> Result, PortError>; + ) -> Result, PlatformError>; // === Clipboard === /// Read text from the clipboard. - fn clipboard_read_text(&self) -> Result, PortError>; + fn clipboard_read_text(&self) -> Result, PlatformError>; /// Write text to the clipboard. - fn clipboard_write_text(&self, text: &str) -> Result<(), PortError>; + fn clipboard_write_text(&self, text: &str) -> Result<(), PlatformError>; // === Logging === @@ -507,32 +507,32 @@ pub trait Port: Send + Sync { // === Configuration === /// Get the configuration directory for storing app settings. - fn get_config_dir(&self) -> Result; + fn get_config_dir(&self) -> Result; /// List available font families on the system. fn list_fonts(&self) -> Vec; } -/// A minimal Port implementation for testing. +/// A minimal Platform implementation for testing. /// -/// This port returns `Unsupported` for most operations, making it suitable +/// Returns `Unsupported` for most operations, making it suitable /// for tests that don't need actual file or dialog operations. -pub struct TestPort; +pub struct TestPlatform; -impl TestPort { - /// Create a new TestPort. +impl TestPlatform { + /// Create a new TestPlatform. pub fn new() -> Self { Self } } -impl Default for TestPort { +impl Default for TestPlatform { fn default() -> Self { Self::new() } } -impl Port for TestPort { +impl Platform for TestPlatform { fn platform_info(&self) -> PlatformInfo { PlatformInfo { os_name: "test".to_string(), @@ -543,8 +543,8 @@ impl Port for TestPort { } } - fn read_file(&self, _ctx: &ProjectContext, _path: &RelativePath) -> Result, PortError> { - Err(PortError::Unsupported) + fn read_file(&self, _ctx: &ProjectContext, _path: &RelativePath) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } fn write_file( @@ -552,67 +552,67 @@ impl Port for TestPort { _ctx: &ProjectContext, _path: &RelativePath, _data: &[u8], - ) -> Result<(), PortError> { - Err(PortError::Unsupported) + ) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported) } fn list_directory( &self, _ctx: &ProjectContext, _path: &RelativePath, - ) -> Result, PortError> { - Err(PortError::Unsupported) + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn read_text_file(&self, _ctx: &ProjectContext, _path: &str) -> Result { - Err(PortError::Unsupported) + fn read_text_file(&self, _ctx: &ProjectContext, _path: &str) -> Result { + Err(PlatformError::Unsupported) } - fn read_binary_file(&self, _ctx: &ProjectContext, _path: &str) -> Result, PortError> { - Err(PortError::Unsupported) + fn read_binary_file(&self, _ctx: &ProjectContext, _path: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn load_app_resource(&self, _name: &str) -> Result, PortError> { - Err(PortError::Unsupported) + fn load_app_resource(&self, _name: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn read_project(&self, _ctx: &ProjectContext) -> Result, PortError> { - Err(PortError::Unsupported) + fn read_project(&self, _ctx: &ProjectContext) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn write_project(&self, _ctx: &ProjectContext, _data: &[u8]) -> Result<(), PortError> { - Err(PortError::Unsupported) + fn write_project(&self, _ctx: &ProjectContext, _data: &[u8]) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported) } - fn load_library(&self, _name: &str) -> Result, PortError> { - Err(PortError::Unsupported) + fn load_library(&self, _name: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn http_get(&self, _url: &str) -> Result, PortError> { - Err(PortError::Unsupported) + fn http_get(&self, _url: &str) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } fn show_open_project_dialog( &self, _filters: &[FileFilter], - ) -> Result, PortError> { - Err(PortError::Unsupported) + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } fn show_save_project_dialog( &self, _filters: &[FileFilter], _default_name: Option<&str>, - ) -> Result, PortError> { - Err(PortError::Unsupported) + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } fn show_open_file_dialog( &self, _ctx: &ProjectContext, _filters: &[FileFilter], - ) -> Result, PortError> { - Err(PortError::Unsupported) + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } fn show_save_file_dialog( @@ -620,19 +620,19 @@ impl Port for TestPort { _ctx: &ProjectContext, _filters: &[FileFilter], _default_name: Option<&str>, - ) -> Result, PortError> { - Err(PortError::Unsupported) + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } fn show_select_folder_dialog( &self, _ctx: &ProjectContext, - ) -> Result, PortError> { - Err(PortError::Unsupported) + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn show_confirm_dialog(&self, _title: &str, _message: &str) -> Result { - Err(PortError::Unsupported) + fn show_confirm_dialog(&self, _title: &str, _message: &str) -> Result { + Err(PlatformError::Unsupported) } fn show_message_dialog( @@ -640,16 +640,16 @@ impl Port for TestPort { _title: &str, _message: &str, _buttons: &[&str], - ) -> Result, PortError> { - Err(PortError::Unsupported) + ) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn clipboard_read_text(&self) -> Result, PortError> { - Err(PortError::Unsupported) + fn clipboard_read_text(&self) -> Result, PlatformError> { + Err(PlatformError::Unsupported) } - fn clipboard_write_text(&self, _text: &str) -> Result<(), PortError> { - Err(PortError::Unsupported) + fn clipboard_write_text(&self, _text: &str) -> Result<(), PlatformError> { + Err(PlatformError::Unsupported) } fn log(&self, _level: LogLevel, _message: &str) {} @@ -658,8 +658,8 @@ impl Port for TestPort { fn performance_mark_with_details(&self, _name: &str, _details: &str) {} - fn get_config_dir(&self) -> Result { - Err(PortError::Unsupported) + fn get_config_dir(&self) -> Result { + Err(PlatformError::Unsupported) } fn list_fonts(&self) -> Vec { @@ -681,22 +681,22 @@ mod tests { #[test] fn test_relative_path_rejects_parent_dir() { - assert!(matches!(RelativePath::new(".."), Err(PortError::SandboxViolation))); - assert!(matches!(RelativePath::new("../file.txt"), Err(PortError::SandboxViolation))); - assert!(matches!(RelativePath::new("subdir/../other.txt"), Err(PortError::SandboxViolation))); - assert!(matches!(RelativePath::new("a/b/../../c.txt"), Err(PortError::SandboxViolation))); + 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(PortError::SandboxViolation))); - assert!(matches!(RelativePath::new("/home/user/file.txt"), Err(PortError::SandboxViolation))); + 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(PortError::SandboxViolation))); - assert!(matches!(RelativePath::new("D:\\Documents\\file.txt"), Err(PortError::SandboxViolation))); + assert!(matches!(RelativePath::new("C:/Users/file.txt"), Err(PlatformError::SandboxViolation))); + assert!(matches!(RelativePath::new("D:\\Documents\\file.txt"), Err(PlatformError::SandboxViolation))); } #[test] @@ -704,7 +704,7 @@ mod tests { 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(PortError::SandboxViolation))); + assert!(matches!(base.join("../escape.txt"), Err(PlatformError::SandboxViolation))); } #[test] @@ -761,21 +761,21 @@ mod tests { #[test] fn test_port_error_from_io_error() { let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "not found"); - assert!(matches!(PortError::from(not_found), PortError::NotFound)); + assert!(matches!(PlatformError::from(not_found), PlatformError::NotFound)); let permission = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); - assert!(matches!(PortError::from(permission), PortError::PermissionDenied)); + assert!(matches!(PlatformError::from(permission), PlatformError::PermissionDenied)); let other = std::io::Error::new(std::io::ErrorKind::Other, "something else"); - assert!(matches!(PortError::from(other), PortError::IoError(_))); + assert!(matches!(PlatformError::from(other), PlatformError::IoError(_))); } #[test] fn test_port_error_display() { - assert_eq!(format!("{}", PortError::Unsupported), "operation not supported on this platform"); - assert_eq!(format!("{}", PortError::NotFound), "not found"); - assert_eq!(format!("{}", PortError::PermissionDenied), "permission denied"); - assert_eq!(format!("{}", PortError::SandboxViolation), "path escapes project sandbox"); - assert_eq!(format!("{}", PortError::NetworkError("timeout".to_string())), "network error: timeout"); - assert_eq!(format!("{}", PortError::LibraryNotFound("math".to_string())), "library not found: math"); + 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] diff --git a/crates/nodebox-desktop/Cargo.toml b/crates/nodebox-desktop/Cargo.toml index 08e0ec72..a55e1fea 100644 --- a/crates/nodebox-desktop/Cargo.toml +++ b/crates/nodebox-desktop/Cargo.toml @@ -40,7 +40,7 @@ smol = "2" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.15" -# DesktopPort dependencies (desktop only) +# DesktopPlatform dependencies (desktop only) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = "0.15" diff --git a/crates/nodebox-desktop/src/app.rs b/crates/nodebox-desktop/src/app.rs index 51d13678..4d577d6a 100644 --- a/crates/nodebox-desktop/src/app.rs +++ b/crates/nodebox-desktop/src/app.rs @@ -2,7 +2,7 @@ use eframe::egui::{self, Pos2, Rect}; use nodebox_core::geometry::Point; -use nodebox_core::port::{Port, ProjectContext}; +use nodebox_core::platform::{Platform, ProjectContext}; use std::sync::Arc; use crate::address_bar::{AddressBar, AddressBarAction}; @@ -24,8 +24,8 @@ use crate::viewer_pane::{HandleResult, ViewerPane}; /// The main NodeBox application. pub struct NodeBoxApp { - /// Port for platform-abstracted file operations. - port: Arc, + /// Platform for platform-abstracted file operations. + port: Arc, /// Project context for the current project (tracks save location). project_context: ProjectContext, state: AppState, @@ -64,13 +64,13 @@ pub struct NodeBoxApp { } impl NodeBoxApp { - /// Create a new NodeBox application instance with a Port for file operations. + /// Create a new NodeBox application instance with a Platform for file operations. /// - /// This is the primary constructor that accepts an `Arc` for + /// This is the primary constructor that accepts an `Arc` for /// platform-abstracted file operations. pub fn new_with_port( cc: &eframe::CreationContext<'_>, - port: Arc, + port: Arc, initial_file: Option, ) -> Self { // Configure the global theme/style @@ -140,7 +140,7 @@ impl NodeBoxApp { /// Create a new NodeBox application instance (legacy constructor). /// - /// This constructor creates a DesktopPort internally for backwards compatibility. + /// 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 { @@ -149,7 +149,7 @@ impl NodeBoxApp { /// Create a new NodeBox application instance, optionally loading an initial file. /// - /// This constructor creates a DesktopPort internally for backwards compatibility. + /// 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<'_>, @@ -159,11 +159,11 @@ impl NodeBoxApp { // Configure the global theme/style theme::configure_style(&cc.egui_ctx); - // Create a default DesktopPort for backwards compatibility + // Create a default DesktopPlatform for backwards compatibility #[cfg(not(target_arch = "wasm32"))] - let port: Arc = Arc::new(crate::DesktopPort::new()); + let port: Arc = Arc::new(crate::DesktopPlatform::new()); #[cfg(target_arch = "wasm32")] - compile_error!("WASM builds must use new_with_port with a custom Port implementation"); + compile_error!("WASM builds must use new_with_port with a custom Platform implementation"); let mut state = AppState::new(); @@ -235,7 +235,7 @@ impl NodeBoxApp { let hash = Self::hash_library(&state.library); let prev_library = Arc::clone(&state.library); Self { - port: Arc::new(crate::DesktopPort::new()), + port: Arc::new(crate::DesktopPlatform::new()), project_context: ProjectContext::new_unsaved(), state, address_bar: AddressBar::new(), @@ -272,7 +272,7 @@ impl NodeBoxApp { let hash = Self::hash_library(&state.library); let prev_library = Arc::clone(&state.library); Self { - port: Arc::new(crate::DesktopPort::new()), + port: Arc::new(crate::DesktopPlatform::new()), project_context: ProjectContext::new_unsaved(), state, address_bar: AddressBar::new(), @@ -321,9 +321,9 @@ impl NodeBoxApp { &mut self.history } - /// Get a reference to the Port for file operations. + /// Get a reference to the Platform for file operations. #[allow(dead_code)] - pub fn port(&self) -> &Arc { + pub fn port(&self) -> &Arc { &self.port } @@ -1185,7 +1185,7 @@ impl NodeBoxApp { } fn open_file(&mut self) { - use nodebox_core::port::FileFilter; + use nodebox_core::platform::FileFilter; match self.port.show_open_project_dialog(&[FileFilter::nodebox()]) { Ok(Some(path)) => { @@ -1248,7 +1248,7 @@ impl NodeBoxApp { } fn save_file_as(&mut self) { - use nodebox_core::port::FileFilter; + use nodebox_core::platform::FileFilter; match self.port.show_save_project_dialog(&[FileFilter::nodebox()], Some("untitled.ndbx")) { Ok(Some(path)) => { @@ -1269,7 +1269,7 @@ impl NodeBoxApp { } fn export_svg(&mut self) { - use nodebox_core::port::FileFilter; + 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")) { @@ -1287,7 +1287,7 @@ impl NodeBoxApp { } fn export_png(&mut self) { - use nodebox_core::port::FileFilter; + 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")) { diff --git a/crates/nodebox-desktop/src/desktop_port.rs b/crates/nodebox-desktop/src/desktop_platform.rs similarity index 78% rename from crates/nodebox-desktop/src/desktop_port.rs rename to crates/nodebox-desktop/src/desktop_platform.rs index 921eb918..455e038e 100644 --- a/crates/nodebox-desktop/src/desktop_port.rs +++ b/crates/nodebox-desktop/src/desktop_platform.rs @@ -1,31 +1,31 @@ -//! Desktop (macOS, Windows, Linux) implementation of the Port trait. +//! Desktop (macOS, Windows, Linux) implementation of the Platform trait. -use nodebox_core::port::{ - DirectoryEntry, FileFilter, LogLevel, PlatformInfo, Port, PortError, ProjectContext, +use nodebox_core::platform::{ + DirectoryEntry, FileFilter, LogLevel, PlatformInfo, Platform, PlatformError, ProjectContext, RelativePath, }; use std::path::{Path, PathBuf}; -/// Desktop implementation of the Port trait. +/// Desktop implementation of the Platform trait. /// /// Uses native filesystem, rfd for dialogs, arboard for clipboard, and ureq for HTTP. #[derive(Debug, Default)] -pub struct DesktopPort; +pub struct DesktopPlatform; -impl DesktopPort { - /// Create a new DesktopPort instance. +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 { + 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(PortError::Other( + Err(PlatformError::Other( "Could not determine library directory".to_string(), )) } @@ -37,25 +37,25 @@ impl DesktopPort { fn validate_within_project( ctx: &ProjectContext, path: &Path, - ) -> Result { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + ) -> 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| { - PortError::IoError(format!("Failed to canonicalize project root: {}", e)) + PlatformError::IoError(format!("Failed to canonicalize project root: {}", e)) })?; let canonical_path = path.canonicalize().map_err(|e| { - PortError::IoError(format!("Failed to canonicalize selected path: {}", 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(|_| PortError::SandboxViolation)?; + .map_err(|_| PlatformError::SandboxViolation)?; RelativePath::new(relative) } else { - Err(PortError::SandboxViolation) + Err(PlatformError::SandboxViolation) } } @@ -65,12 +65,12 @@ impl DesktopPort { fn validate_save_path_within_project( ctx: &ProjectContext, path: &Path, - ) -> Result { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + ) -> Result { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; // Canonicalize the project root let canonical_root = root.canonicalize().map_err(|e| { - PortError::IoError(format!("Failed to canonicalize project root: {}", e)) + PlatformError::IoError(format!("Failed to canonicalize project root: {}", e)) })?; // For the save path, canonicalize the parent directory (which should exist) @@ -80,7 +80,7 @@ impl DesktopPort { // 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| { - PortError::IoError(format!( + PlatformError::IoError(format!( "Failed to canonicalize parent directory: {}", e )) @@ -92,7 +92,7 @@ impl DesktopPort { loop { if ancestor.exists() { break ancestor.canonicalize().map_err(|e| { - PortError::IoError(format!( + PlatformError::IoError(format!( "Failed to canonicalize ancestor: {}", e )) @@ -101,7 +101,7 @@ impl DesktopPort { if let Some(p) = ancestor.parent() { ancestor = p.to_path_buf(); } else { - return Err(PortError::SandboxViolation); + return Err(PlatformError::SandboxViolation); } } }; @@ -114,7 +114,7 @@ impl DesktopPort { } else { canonical_parent .strip_prefix(&canonical_root) - .map_err(|_| PortError::SandboxViolation)? + .map_err(|_| PlatformError::SandboxViolation)? .to_path_buf() }; @@ -151,10 +151,10 @@ impl DesktopPort { RelativePath::new(full_relative) } else { - Err(PortError::SandboxViolation) + Err(PlatformError::SandboxViolation) } } else { - Err(PortError::SandboxViolation) + Err(PlatformError::SandboxViolation) } } else { // Path has no parent - just a filename, which is fine @@ -163,15 +163,15 @@ impl DesktopPort { } } -impl Port for DesktopPort { +impl Platform for DesktopPlatform { fn platform_info(&self) -> PlatformInfo { PlatformInfo::current() } - fn read_file(&self, ctx: &ProjectContext, path: &RelativePath) -> Result, PortError> { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + 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(PortError::from) + std::fs::read(&full_path).map_err(PlatformError::from) } fn write_file( @@ -179,8 +179,8 @@ impl Port for DesktopPort { ctx: &ProjectContext, path: &RelativePath, data: &[u8], - ) -> Result<(), PortError> { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + ) -> 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 @@ -188,15 +188,15 @@ impl Port for DesktopPort { std::fs::create_dir_all(parent)?; } - std::fs::write(&full_path, data).map_err(PortError::from) + std::fs::write(&full_path, data).map_err(PlatformError::from) } fn list_directory( &self, ctx: &ProjectContext, path: &RelativePath, - ) -> Result, PortError> { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + ) -> 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)? @@ -213,22 +213,22 @@ impl Port for DesktopPort { Ok(entries) } - fn read_text_file(&self, ctx: &ProjectContext, path: &str) -> Result { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + 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(|_| PortError::IoError("Invalid UTF-8".to_string())) + String::from_utf8(bytes).map_err(|_| PlatformError::IoError("Invalid UTF-8".to_string())) } - fn read_binary_file(&self, ctx: &ProjectContext, path: &str) -> Result, PortError> { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + 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(PortError::from) + std::fs::read(&full_path).map_err(PlatformError::from) } - fn load_app_resource(&self, name: &str) -> Result, PortError> { + 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() @@ -243,50 +243,50 @@ impl Port for DesktopPort { for dir in resource_dirs.iter().flatten() { let path = dir.join(name); if path.exists() { - return std::fs::read(&path).map_err(PortError::from); + return std::fs::read(&path).map_err(PlatformError::from); } } - Err(PortError::NotFound) + Err(PlatformError::NotFound) } - fn read_project(&self, ctx: &ProjectContext) -> Result, PortError> { - let path = ctx.project_path().ok_or(PortError::Unsupported)?; - std::fs::read(&path).map_err(PortError::from) + 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<(), PortError> { - let path = ctx.project_path().ok_or(PortError::Unsupported)?; + 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(PortError::from) + std::fs::write(&path, data).map_err(PlatformError::from) } - fn load_library(&self, name: &str) -> Result, PortError> { + 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(PortError::LibraryNotFound(name.to_string())); + return Err(PlatformError::LibraryNotFound(name.to_string())); } - std::fs::read(&library_path).map_err(PortError::from) + std::fs::read(&library_path).map_err(PlatformError::from) } - fn http_get(&self, url: &str) -> Result, PortError> { + fn http_get(&self, url: &str) -> Result, PlatformError> { let response = ureq::get(url) .call() - .map_err(|e| PortError::NetworkError(e.to_string()))?; + .map_err(|e| PlatformError::NetworkError(e.to_string()))?; let mut bytes = Vec::new(); response .into_reader() .read_to_end(&mut bytes) - .map_err(|e| PortError::NetworkError(e.to_string()))?; + .map_err(|e| PlatformError::NetworkError(e.to_string()))?; Ok(bytes) } @@ -294,7 +294,7 @@ impl Port for DesktopPort { fn show_open_project_dialog( &self, filters: &[FileFilter], - ) -> Result, PortError> { + ) -> Result, PlatformError> { let mut dialog = rfd::FileDialog::new(); for filter in filters { @@ -309,7 +309,7 @@ impl Port for DesktopPort { &self, filters: &[FileFilter], default_name: Option<&str>, - ) -> Result, PortError> { + ) -> Result, PlatformError> { let mut dialog = rfd::FileDialog::new(); for filter in filters { @@ -328,8 +328,8 @@ impl Port for DesktopPort { &self, ctx: &ProjectContext, filters: &[FileFilter], - ) -> Result, PortError> { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + ) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; let mut dialog = rfd::FileDialog::new(); // Start in the project directory @@ -355,8 +355,8 @@ impl Port for DesktopPort { ctx: &ProjectContext, filters: &[FileFilter], default_name: Option<&str>, - ) -> Result, PortError> { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + ) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; let mut dialog = rfd::FileDialog::new(); // Start in the project directory @@ -384,8 +384,8 @@ impl Port for DesktopPort { fn show_select_folder_dialog( &self, ctx: &ProjectContext, - ) -> Result, PortError> { - let root = ctx.root.as_ref().ok_or(PortError::Unsupported)?; + ) -> Result, PlatformError> { + let root = ctx.root.as_ref().ok_or(PlatformError::Unsupported)?; let mut dialog = rfd::FileDialog::new(); // Start in the project directory @@ -401,7 +401,7 @@ impl Port for DesktopPort { } } - fn show_confirm_dialog(&self, title: &str, message: &str) -> Result { + fn show_confirm_dialog(&self, title: &str, message: &str) -> Result { let result = rfd::MessageDialog::new() .set_title(title) .set_description(message) @@ -416,7 +416,7 @@ impl Port for DesktopPort { title: &str, message: &str, buttons: &[&str], - ) -> Result, PortError> { + ) -> Result, PlatformError> { // rfd doesn't support custom button labels directly // Map to available button types based on count let button_type = match buttons.len() { @@ -448,24 +448,24 @@ impl Port for DesktopPort { Ok(index) } - fn clipboard_read_text(&self) -> Result, PortError> { + fn clipboard_read_text(&self) -> Result, PlatformError> { let mut clipboard = - arboard::Clipboard::new().map_err(|e| PortError::Other(e.to_string()))?; + 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(PortError::Other(e.to_string())), + Err(e) => Err(PlatformError::Other(e.to_string())), } } - fn clipboard_write_text(&self, text: &str) -> Result<(), PortError> { + fn clipboard_write_text(&self, text: &str) -> Result<(), PlatformError> { let mut clipboard = - arboard::Clipboard::new().map_err(|e| PortError::Other(e.to_string()))?; + arboard::Clipboard::new().map_err(|e| PlatformError::Other(e.to_string()))?; clipboard .set_text(text) - .map_err(|e| PortError::Other(e.to_string())) + .map_err(|e| PlatformError::Other(e.to_string())) } fn log(&self, level: LogLevel, message: &str) { @@ -485,13 +485,13 @@ impl Port for DesktopPort { log::trace!("[PERF] {} - {}", name, details); } - fn get_config_dir(&self) -> Result { + 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(PortError::Other( + Err(PlatformError::Other( "Could not determine config directory".to_string(), )) } @@ -518,7 +518,7 @@ mod tests { #[test] fn test_platform_info() { - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); let info = port.platform_info(); assert!(info.has_filesystem); @@ -529,7 +529,7 @@ mod tests { #[test] fn test_read_write_file() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); let path = RelativePath::new("test.txt").unwrap(); let data = b"Hello, World!"; @@ -545,7 +545,7 @@ mod tests { #[test] fn test_write_creates_directories() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); let path = RelativePath::new("subdir/nested/file.txt").unwrap(); let data = b"nested content"; @@ -559,18 +559,18 @@ mod tests { #[test] fn test_read_nonexistent_file() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); let path = RelativePath::new("nonexistent.txt").unwrap(); let result = port.read_file(&ctx, &path); - assert!(matches!(result, Err(PortError::NotFound))); + assert!(matches!(result, Err(PlatformError::NotFound))); } #[test] fn test_list_directory() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Create some files and directories port.write_file(&ctx, &RelativePath::new("file1.txt").unwrap(), b"1") @@ -597,7 +597,7 @@ mod tests { #[test] fn test_read_write_project() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); let data = b"project content"; @@ -609,15 +609,15 @@ mod tests { #[test] fn test_load_library_not_found() { - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); let result = port.load_library("nonexistent_library_xyz"); - assert!(matches!(result, Err(PortError::LibraryNotFound(_)))); + assert!(matches!(result, Err(PlatformError::LibraryNotFound(_)))); } #[test] fn test_get_config_dir() { - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); let result = port.get_config_dir(); assert!(result.is_ok()); @@ -629,7 +629,7 @@ mod tests { #[test] fn test_log_levels() { - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Just verify these don't panic port.log(LogLevel::Error, "test error"); @@ -640,7 +640,7 @@ mod tests { #[test] fn test_performance_marks() { - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Just verify these don't panic port.performance_mark("test-mark"); @@ -656,7 +656,7 @@ mod tests { std::fs::write(&file_path, "test content").unwrap(); // Validation should succeed - let result = DesktopPort::validate_within_project(&ctx, &file_path); + 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")); @@ -673,7 +673,7 @@ mod tests { std::fs::write(&file_path, "nested content").unwrap(); // Validation should succeed - let result = DesktopPort::validate_within_project(&ctx, &file_path); + 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")); @@ -691,8 +691,8 @@ mod tests { std::fs::write(&outside_file, "outside content").unwrap(); // Validation should fail with SandboxViolation - let result = DesktopPort::validate_within_project(&ctx, &outside_file); - assert!(matches!(result, Err(PortError::SandboxViolation))); + let result = DesktopPlatform::validate_within_project(&ctx, &outside_file); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); } #[test] @@ -707,8 +707,8 @@ mod tests { std::fs::write(&parent_file, "parent content").unwrap(); // Validation should fail - let result = DesktopPort::validate_within_project(&ctx, &parent_file); - assert!(matches!(result, Err(PortError::SandboxViolation))); + let result = DesktopPlatform::validate_within_project(&ctx, &parent_file); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); } #[test] @@ -718,7 +718,7 @@ mod tests { // 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 = DesktopPort::validate_save_path_within_project(&ctx, &save_path); + 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")); @@ -735,7 +735,7 @@ mod tests { // Save path to the existing subdirectory let save_path = subdir.join("new_image.png"); - let result = DesktopPort::validate_save_path_within_project(&ctx, &save_path); + 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")); @@ -751,14 +751,14 @@ mod tests { // Try to save outside the project let outside_path = temp_dir.path().join("outside.txt"); - let result = DesktopPort::validate_save_path_within_project(&ctx, &outside_path); - assert!(matches!(result, Err(PortError::SandboxViolation))); + 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 = DesktopPort::new(); + let port = DesktopPlatform::new(); // Create a text file let file_path = ctx.root.as_ref().unwrap().join("test.txt"); @@ -772,7 +772,7 @@ mod tests { #[test] fn test_read_text_file_nested() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Create a nested text file let subdir = ctx.root.as_ref().unwrap().join("assets"); @@ -787,7 +787,7 @@ mod tests { #[test] fn test_read_text_file_invalid_utf8() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Create a binary file (invalid UTF-8) let file_path = ctx.root.as_ref().unwrap().join("binary.dat"); @@ -795,37 +795,37 @@ mod tests { // Should fail with IoError for invalid UTF-8 let result = port.read_text_file(&ctx, "binary.dat"); - assert!(matches!(result, Err(PortError::IoError(_)))); + assert!(matches!(result, Err(PlatformError::IoError(_)))); } #[test] fn test_read_text_file_rejects_sandbox_violation() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Try to read with ".." in path let result = port.read_text_file(&ctx, "../escape.txt"); - assert!(matches!(result, Err(PortError::SandboxViolation))); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); // Try with absolute path let result = port.read_text_file(&ctx, "/etc/passwd"); - assert!(matches!(result, Err(PortError::SandboxViolation))); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); } #[test] fn test_read_text_file_unsaved_project() { - let port = DesktopPort::new(); + 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(PortError::Unsupported))); + assert!(matches!(result, Err(PlatformError::Unsupported))); } #[test] fn test_read_binary_file() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Create a binary file let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes @@ -840,19 +840,19 @@ mod tests { #[test] fn test_read_binary_file_rejects_sandbox_violation() { let (_temp_dir, ctx) = create_test_context(); - let port = DesktopPort::new(); + let port = DesktopPlatform::new(); // Try to read with ".." in path let result = port.read_binary_file(&ctx, "../escape.bin"); - assert!(matches!(result, Err(PortError::SandboxViolation))); + assert!(matches!(result, Err(PlatformError::SandboxViolation))); } #[test] fn test_load_app_resource_not_found() { - let port = DesktopPort::new(); + 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(PortError::NotFound))); + assert!(matches!(result, Err(PlatformError::NotFound))); } } diff --git a/crates/nodebox-desktop/src/eval.rs b/crates/nodebox-desktop/src/eval.rs index 8ab508ae..2f6e2d54 100644 --- a/crates/nodebox-desktop/src/eval.rs +++ b/crates/nodebox-desktop/src/eval.rs @@ -7,7 +7,7 @@ use nodebox_core::geometry::font; use nodebox_core::node::{Node, NodeLibrary, EvalError}; use nodebox_core::node::PortRange; use nodebox_core::Value; -use nodebox_core::port::{Port, ProjectContext}; +use nodebox_core::platform::{Platform, ProjectContext}; use nodebox_core::ops; use ops::data::DataValue; use crate::render_worker::CancellationToken; @@ -294,7 +294,7 @@ impl NodeOutput { /// The `port` and `project_context` are used for sandboxed file access (e.g., import_svg). pub fn evaluate_network( library: &NodeLibrary, - port: &Arc, + port: &Arc, project_context: &ProjectContext, ) -> (Vec, NodeOutput, Vec) { let network = &library.root; @@ -360,7 +360,7 @@ pub fn evaluate_network_cancellable( library: &NodeLibrary, cancel_token: &CancellationToken, cache: &mut HashMap, - port: &Arc, + port: &Arc, project_context: &ProjectContext, ) -> EvalOutcome { let network = &library.root; @@ -600,7 +600,7 @@ fn evaluate_node_cancellable( node_name: &str, cache: &mut HashMap, cancel_token: &CancellationToken, - port: &Arc, + port: &Arc, project_context: &ProjectContext, ) -> EvalResult { // Check cancellation before starting this node @@ -746,7 +746,7 @@ fn evaluate_node( network: &Node, node_name: &str, cache: &mut HashMap, - port: &Arc, + port: &Arc, project_context: &ProjectContext, ) -> EvalResult { // Check cache first @@ -1013,7 +1013,7 @@ fn require_paths(inputs: &HashMap, node_name: &str, port_nam fn execute_node( node: &Node, inputs: &HashMap, - port: &Arc, + port: &Arc, project_context: &ProjectContext, ) -> EvalResult { // Get the function name (prototype determines what the node does) @@ -2271,11 +2271,11 @@ fn execute_node( mod tests { use super::*; use nodebox_core::node::{Port as NodePort, Connection, PortRange}; - use nodebox_core::port::{TestPort, ProjectContext}; + use nodebox_core::platform::{TestPlatform, ProjectContext}; - /// Create a test port and project context for evaluation tests. - fn test_port_and_context() -> (Arc, ProjectContext) { - (Arc::new(TestPort::new()), ProjectContext::new_unsaved()) + /// Create a test platform and project context for evaluation tests. + fn test_port_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) } #[test] diff --git a/crates/nodebox-desktop/src/lib.rs b/crates/nodebox-desktop/src/lib.rs index 7c768ebd..c2cf64f2 100644 --- a/crates/nodebox-desktop/src/lib.rs +++ b/crates/nodebox-desktop/src/lib.rs @@ -20,9 +20,9 @@ //! - `vello_renderer` - High-level Vello renderer wrapper #[cfg(not(target_arch = "wasm32"))] -mod desktop_port; +mod desktop_platform; #[cfg(not(target_arch = "wasm32"))] -pub use desktop_port::DesktopPort; +pub use desktop_platform::DesktopPlatform; mod address_bar; mod animation_bar; @@ -62,6 +62,7 @@ pub use state::{populate_default_ports, AppState, Notification, NotificationLeve // 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 @@ -80,14 +81,14 @@ use std::sync::Arc; /// Run the NodeBox GUI application. /// -/// This is a convenience function that creates a DesktopPort and runs the app. +/// 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(); - // Create the desktop port for file operations - let port: Arc = Arc::new(crate::DesktopPort::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() diff --git a/crates/nodebox-desktop/src/parameter_panel.rs b/crates/nodebox-desktop/src/parameter_panel.rs index e7a97f77..f369f3f6 100644 --- a/crates/nodebox-desktop/src/parameter_panel.rs +++ b/crates/nodebox-desktop/src/parameter_panel.rs @@ -5,7 +5,7 @@ use eframe::egui::{self, Sense}; use nodebox_core::geometry::Color; use nodebox_core::node::{PortType, Widget}; use nodebox_core::Value; -use nodebox_core::port::{FileFilter, Port, PortError, ProjectContext}; +use nodebox_core::platform::{FileFilter, Platform, PlatformError, ProjectContext}; use crate::components; use crate::state::AppState; use crate::theme; @@ -63,7 +63,7 @@ impl ParameterPanel { &mut self, ui: &mut egui::Ui, state: &mut AppState, - port: &dyn Port, + port: &dyn Platform, project_context: &ProjectContext, ) { // Clear drag state when the primary button is released @@ -363,7 +363,7 @@ impl ParameterPanel { is_connected: bool, node_name: &str, node_prototype: Option<&str>, - io_port: &dyn Port, + io_port: &dyn Platform, project_context: &ProjectContext, ) { let is_label_draggable = !is_connected @@ -483,7 +483,7 @@ impl ParameterPanel { port: &mut nodebox_core::node::Port, node_name: &str, node_prototype: Option<&str>, - io_port: &dyn Port, + io_port: &dyn Platform, project_context: &ProjectContext, ) { let port_key = (node_name.to_string(), port.name.clone()); @@ -798,7 +798,7 @@ impl ParameterPanel { *path = relative_path.to_string(); } Ok(None) => {} // User cancelled - Err(PortError::SandboxViolation) => { + Err(PlatformError::SandboxViolation) => { let _ = io_port.show_message_dialog( "File Outside Project", "Please copy the file to your project folder first.", diff --git a/crates/nodebox-desktop/src/render_worker.rs b/crates/nodebox-desktop/src/render_worker.rs index 1add76c1..e3758f15 100644 --- a/crates/nodebox-desktop/src/render_worker.rs +++ b/crates/nodebox-desktop/src/render_worker.rs @@ -7,7 +7,7 @@ use std::thread; use std::time::Instant; use nodebox_core::geometry::Path as GeoPath; use nodebox_core::node::NodeLibrary; -use nodebox_core::port::{Port, ProjectContext}; +use nodebox_core::platform::{Platform, ProjectContext}; use crate::eval::{NodeError, NodeOutput}; /// Token for cooperative cancellation of render operations. @@ -57,7 +57,7 @@ pub enum RenderRequest { id: RenderRequestId, library: Arc, cancel_token: CancellationToken, - port: Arc, + port: Arc, project_context: ProjectContext, }, /// Shut down the worker thread. @@ -181,7 +181,7 @@ impl RenderWorkerHandle { id: RenderRequestId, library: Arc, cancel_token: CancellationToken, - port: Arc, + port: Arc, project_context: ProjectContext, ) { if let Some(ref tx) = self.request_tx { @@ -274,10 +274,10 @@ fn drain_to_latest( mut id: RenderRequestId, mut library: Arc, mut cancel_token: CancellationToken, - mut port: Arc, + mut port: Arc, mut project_context: ProjectContext, rx: &mpsc::Receiver, -) -> (RenderRequestId, Arc, CancellationToken, Arc, ProjectContext) { +) -> (RenderRequestId, Arc, CancellationToken, Arc, ProjectContext) { while let Ok(req) = rx.try_recv() { match req { RenderRequest::Evaluate { diff --git a/crates/nodebox-desktop/tests/cancellation_tests.rs b/crates/nodebox-desktop/tests/cancellation_tests.rs index 3eb22955..3e82f38a 100644 --- a/crates/nodebox-desktop/tests/cancellation_tests.rs +++ b/crates/nodebox-desktop/tests/cancellation_tests.rs @@ -8,11 +8,11 @@ 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::port::{Port as PortTrait, ProjectContext, TestPort}; +use nodebox_core::platform::{Platform, ProjectContext, TestPlatform}; -/// Create a test port and project context for evaluation tests. -fn test_port_and_context() -> (Arc, ProjectContext) { - (Arc::new(TestPort::new()), ProjectContext::new_unsaved()) +/// Create a test platform and project context for evaluation tests. +fn test_port_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) } /// Helper to create a library with a large grid that generates many iterations. @@ -185,7 +185,7 @@ fn test_cancellation_response_time() { let library_clone = library.clone(); let handle = thread::spawn(move || { let mut thread_cache: HashMap = HashMap::new(); - let port: Arc = Arc::new(TestPort::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) }); diff --git a/crates/nodebox-desktop/tests/file_tests.rs b/crates/nodebox-desktop/tests/file_tests.rs index fe8926c7..93a57ae2 100644 --- a/crates/nodebox-desktop/tests/file_tests.rs +++ b/crates/nodebox-desktop/tests/file_tests.rs @@ -11,11 +11,11 @@ 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::port::{Port as PortTrait, ProjectContext, TestPort}; +use nodebox_core::platform::{Platform, ProjectContext, TestPlatform}; -/// Create a test port and project context for evaluation tests. -fn test_port_and_context() -> (Arc, ProjectContext) { - (Arc::new(TestPort::new()), ProjectContext::new_unsaved()) +/// Create a test platform and project context for evaluation tests. +fn test_port_and_context() -> (Arc, ProjectContext) { + (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) } /// Get the path to the examples directory. diff --git a/docs/rust-translation-plan.md b/docs/rust-translation-plan.md index 65d2c93b..9f7df042 100644 --- a/docs/rust-translation-plan.md +++ b/docs/rust-translation-plan.md @@ -62,7 +62,7 @@ nodebox/ │ │ │ │ └── upgrade.rs # Version migration │ │ │ ├── svg/ # SVG renderer (merged from nodebox-svg) │ │ │ │ └── mod.rs -│ │ │ ├── port.rs # Port definitions (merged from nodebox-port) +│ │ │ ├── platform.rs # Platform definitions (merged from nodebox-port) │ │ │ └── value.rs # Runtime value types │ │ └── Cargo.toml │ │ @@ -1688,7 +1688,7 @@ All three phases have been implemented: - `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::port`: Port definitions — merged from `nodebox-port` + - `nodebox-core::platform`: Platform definitions — merged from `nodebox-port` ### Phase 2: GUI ✅ - `nodebox-desktop`: egui-based desktop GUI application (renamed from `nodebox-gui`) with: diff --git a/docs/server-api.md b/docs/server-api.md index f3353f2a..d8d4029e 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -195,15 +195,15 @@ All errors return JSON: --- -## WebPort Implementation Notes +## WebPlatform Implementation Notes -The JavaScript WebPort implementation maps Port trait methods to API calls: +The JavaScript WebPlatform implementation maps Platform trait methods to API calls: ```javascript -const webPort = { +const webPlatform = { async read_file(ctx, path) { const resp = await fetch(`/api/v1/projects/${ctx.project_id}/assets/${path}`); - if (!resp.ok) throw new PortError(resp.status === 404 ? 'NotFound' : 'IoError'); + if (!resp.ok) throw new PlatformError(resp.status === 404 ? 'NotFound' : 'IoError'); return new Uint8Array(await resp.arrayBuffer()); }, @@ -213,18 +213,18 @@ const webPort = { headers: { 'Content-Type': 'application/octet-stream' }, body: data }); - if (!resp.ok) throw new PortError('IoError'); + 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 PortError(resp.status === 404 ? 'NotFound' : 'IoError'); + 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 PortError(resp.status === 404 ? 'NotFound' : 'IoError'); + if (!resp.ok) throw new PlatformError(resp.status === 404 ? 'NotFound' : 'IoError'); return new Uint8Array(await resp.arrayBuffer()); }, @@ -234,18 +234,18 @@ const webPort = { headers: { 'Content-Type': 'application/xml' }, body: data }); - if (!resp.ok) throw new PortError('IoError'); + if (!resp.ok) throw new PlatformError('IoError'); }, async load_library(name) { const resp = await fetch(`/api/v1/libraries/${name}`); - if (!resp.ok) throw new PortError(resp.status === 404 ? 'LibraryNotFound' : 'IoError'); + 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 PortError('NetworkError'); + if (!resp.ok) throw new PlatformError('NetworkError'); return new Uint8Array(await resp.arrayBuffer()); }, @@ -360,7 +360,7 @@ const webPort = { throw e; } } - throw new PortError('Unsupported'); + throw new PlatformError('Unsupported'); }, async show_confirm_dialog(title, message) { @@ -401,7 +401,7 @@ const webPort = { get_config_dir() { // Web has no config directory; return a virtual path - throw new PortError('Unsupported'); + throw new PlatformError('Unsupported'); }, platform_info() { @@ -418,10 +418,10 @@ const webPort = { ### File System Access API -For browsers that support the File System Access API (Chrome, Edge), the WebPort can provide a more native-like experience: +For browsers that support the File System Access API (Chrome, Edge), the WebPlatform can provide a more native-like experience: ```javascript -class FileSystemAccessPort { +class FileSystemAccessPlatform { constructor(directoryHandle) { this.root = directoryHandle; } From ca3ebebe4639b44fb8198212dc91bd2afde22be0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 17:24:18 +0000 Subject: [PATCH 099/100] Remove Port as NodePort alias, now unnecessary The alias was needed to disambiguate nodebox_core::node::Port from nodebox_core::port::Port. Since the latter is now Platform, plain Port is unambiguous. https://claude.ai/code/session_01MVWg5jAgGpHUSLpY2cjrKW --- crates/nodebox-desktop/src/eval.rs | 406 ++++++++++++++--------------- 1 file changed, 203 insertions(+), 203 deletions(-) diff --git a/crates/nodebox-desktop/src/eval.rs b/crates/nodebox-desktop/src/eval.rs index 2f6e2d54..f901d938 100644 --- a/crates/nodebox-desktop/src/eval.rs +++ b/crates/nodebox-desktop/src/eval.rs @@ -2270,7 +2270,7 @@ fn execute_node( #[cfg(test)] mod tests { use super::*; - use nodebox_core::node::{Port as NodePort, Connection, PortRange}; + use nodebox_core::node::{Port, Connection, PortRange}; use nodebox_core::platform::{TestPlatform, ProjectContext}; /// Create a test platform and project context for evaluation tests. @@ -2285,9 +2285,9 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::new(100.0, 100.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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"); @@ -2307,17 +2307,17 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::new(100.0, 100.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))) - .with_input(NodePort::color("stroke", Color::BLACK)) - .with_input(NodePort::float("strokeWidth", 2.0)) + .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"); @@ -2341,21 +2341,21 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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(NodePort::point("position", Point::new(100.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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(NodePort::geometry("shapes")) + .with_input(Port::geometry("shapes")) ) .with_connection(Connection::new("ellipse1", "merge1", "shapes")) .with_connection(Connection::new("rect1", "merge1", "shapes")) @@ -2374,9 +2374,9 @@ mod tests { .with_child( Node::new("rect1") .with_prototype("corevector.rect") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 80.0)) - .with_input(NodePort::float("height", 40.0)) + .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"); @@ -2396,9 +2396,9 @@ mod tests { .with_child( Node::new("line1") .with_prototype("corevector.line") - .with_input(NodePort::point("point1", Point::new(0.0, 0.0))) - .with_input(NodePort::point("point2", Point::new(100.0, 50.0))) - .with_input(NodePort::int("points", 2)) + .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"); @@ -2418,10 +2418,10 @@ mod tests { .with_child( Node::new("polygon1") .with_prototype("corevector.polygon") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("radius", 50.0)) - .with_input(NodePort::int("sides", 6)) - .with_input(NodePort::boolean("align", true)) + .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"); @@ -2442,10 +2442,10 @@ mod tests { .with_child( Node::new("star1") .with_prototype("corevector.star") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::int("points", 5)) - .with_input(NodePort::float("outer", 50.0)) - .with_input(NodePort::float("inner", 25.0)) + .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"); @@ -2465,12 +2465,12 @@ mod tests { .with_child( Node::new("arc1") .with_prototype("corevector.arc") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) - .with_input(NodePort::float("start_angle", 0.0)) - .with_input(NodePort::float("degrees", 180.0)) - .with_input(NodePort::string("type", "pie")) + .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"); @@ -2486,15 +2486,15 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::point("translate", Point::new(100.0, 50.0))) + .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"); @@ -2519,16 +2519,16 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::point("scale", Point::new(50.0, 200.0))) // 50% x, 200% y - .with_input(NodePort::point("origin", Point::ZERO)) + .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"); @@ -2550,19 +2550,19 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::int("copies", 3)) - .with_input(NodePort::string("order", "tsr")) - .with_input(NodePort::point("translate", Point::new(60.0, 0.0))) - .with_input(NodePort::float("rotate", 0.0)) - .with_input(NodePort::point("scale", Point::new(100.0, 100.0))) + .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"); @@ -2588,9 +2588,9 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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 @@ -2606,10 +2606,10 @@ mod tests { .with_child( Node::new("colorize1") .with_prototype("corevector.colorize") - .with_input(NodePort::geometry("shape")) - .with_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))) - .with_input(NodePort::color("stroke", Color::BLACK)) - .with_input(NodePort::float("strokeWidth", 2.0)) + .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"); @@ -2642,15 +2642,15 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::int("points", 20)) + .with_input(Port::geometry("shape")) + .with_input(Port::int("points", 20)) ) .with_connection(Connection::new("ellipse1", "resample1", "shape")) .with_rendered_child("resample1"); @@ -2669,18 +2669,18 @@ mod tests { .with_child( Node::new("grid1") .with_prototype("corevector.grid") - .with_input(NodePort::int("columns", 3)) - .with_input(NodePort::int("rows", 3)) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) - .with_input(NodePort::point("position", Point::ZERO)) + .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(NodePort::geometry("points").with_port_range(PortRange::List)) - .with_input(NodePort::boolean("closed", false)) + .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"); @@ -2703,9 +2703,9 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::new(100.0, 50.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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"); @@ -2730,9 +2730,9 @@ mod tests { .with_child( Node::new("rect1") .with_prototype("corevector.rect") - .with_input(NodePort::point("position", Point::new(-50.0, 25.0))) - .with_input(NodePort::float("width", 80.0)) - .with_input(NodePort::float("height", 40.0)) + .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"); @@ -2755,10 +2755,10 @@ mod tests { .with_child( Node::new("rect1") .with_prototype("corevector.rect") - .with_input(NodePort::point("position", Point::new(0.0, 0.0))) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) - .with_input(NodePort::point("roundness", Point::new(10.0, 10.0))) + .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"); @@ -2776,10 +2776,10 @@ mod tests { .with_child( Node::new("polygon1") .with_prototype("corevector.polygon") - .with_input(NodePort::point("position", Point::new(200.0, -100.0))) - .with_input(NodePort::float("radius", 50.0)) - .with_input(NodePort::int("sides", 6)) - .with_input(NodePort::boolean("align", true)) + .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"); @@ -2802,10 +2802,10 @@ mod tests { .with_child( Node::new("star1") .with_prototype("corevector.star") - .with_input(NodePort::point("position", Point::new(75.0, 75.0))) - .with_input(NodePort::int("points", 5)) - .with_input(NodePort::float("outer", 50.0)) - .with_input(NodePort::float("inner", 25.0)) + .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"); @@ -2829,12 +2829,12 @@ mod tests { .with_child( Node::new("arc1") .with_prototype("corevector.arc") - .with_input(NodePort::point("position", Point::new(50.0, -50.0))) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) - .with_input(NodePort::float("start_angle", 0.0)) - .with_input(NodePort::float("degrees", 180.0)) - .with_input(NodePort::string("type", "pie")) + .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"); @@ -2857,19 +2857,19 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::new(0.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::int("copies", 3)) - .with_input(NodePort::string("order", "tsr")) - .with_input(NodePort::point("translate", Point::new(60.0, 0.0))) - .with_input(NodePort::float("rotate", 0.0)) - .with_input(NodePort::point("scale", Point::new(100.0, 100.0))) + .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"); @@ -2896,18 +2896,18 @@ mod tests { .with_child( Node::new("grid1") .with_prototype("corevector.grid") - .with_input(NodePort::int("columns", 3)) - .with_input(NodePort::int("rows", 3)) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) - .with_input(NodePort::point("position", Point::new(50.0, 50.0))) + .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(NodePort::geometry("points").with_port_range(PortRange::List)) - .with_input(NodePort::boolean("closed", false)) + .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"); @@ -2931,17 +2931,17 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::new(0.0, 0.0))) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::string("scope", "points")) - .with_input(NodePort::point("offset", Point::new(10.0, 10.0))) - .with_input(NodePort::int("seed", 42)) + .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"); @@ -2960,18 +2960,18 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::new(0.0, 0.0))) - .with_input(NodePort::float("width", 200.0)) - .with_input(NodePort::float("height", 100.0)) + .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(NodePort::geometry("shape")) - .with_input(NodePort::point("position", Point::new(100.0, 100.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)) - .with_input(NodePort::boolean("keep_proportions", true)) + .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"); @@ -3048,31 +3048,31 @@ mod tests { .with_child( Node::new("rect1") .with_prototype("corevector.rect") - .with_input(NodePort::point("position", Point::new(-100.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)), + .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(NodePort::point("position", Point::new(0.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)), + .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(NodePort::point("position", Point::new(100.0, 0.0))) - .with_input(NodePort::float("radius", 25.0)) - .with_input(NodePort::int("sides", 6)), + .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(NodePort::geometry("list1").with_port_range(PortRange::List)) - .with_input(NodePort::geometry("list2").with_port_range(PortRange::List)) - .with_input(NodePort::geometry("list3").with_port_range(PortRange::List)), + .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")) @@ -3101,41 +3101,41 @@ mod tests { .with_child( Node::new("rect1") .with_prototype("corevector.rect") - .with_input(NodePort::point("position", Point::new(-100.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)), + .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(NodePort::point("position", Point::new(0.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)), + .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(NodePort::point("position", Point::new(100.0, 0.0))) - .with_input(NodePort::float("radius", 25.0)) - .with_input(NodePort::int("sides", 6)), + .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(NodePort::geometry("shape")) - .with_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))), + .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(NodePort::geometry("shape")) - .with_input(NodePort::color("fill", Color::rgb(0.0, 1.0, 0.0))), + .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(NodePort::geometry("shape")) - .with_input(NodePort::color("fill", Color::rgb(0.0, 0.0, 1.0))), + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(0.0, 0.0, 1.0))), ) .with_child( Node::new("combine1") @@ -3175,15 +3175,15 @@ mod tests { .with_child( Node::new("rect1") .with_prototype("corevector.rect") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)), + .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(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))), + .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))), ) .with_connection(Connection::new("rect1", "colorize1", "shape")) .with_rendered_child("colorize1"); @@ -3209,16 +3209,16 @@ mod tests { .with_child( Node::new("rect1") .with_prototype("corevector.rect") - .with_input(NodePort::point("position", Point::new(-100.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)), + .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(NodePort::point("position", Point::new(0.0, 0.0))) - .with_input(NodePort::float("width", 50.0)) - .with_input(NodePort::float("height", 50.0)), + .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") @@ -3251,19 +3251,19 @@ mod tests { .with_child( Node::new("grid1") .with_prototype("corevector.grid") - .with_input(NodePort::int("columns", 10)) - .with_input(NodePort::int("rows", 10)) - .with_input(NodePort::float("width", 300.0)) - .with_input(NodePort::float("height", 300.0)) - .with_input(NodePort::point("position", Point::ZERO)), + .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(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 20.0)) - .with_input(NodePort::float("height", 20.0)) - .with_input(NodePort::point("roundness", Point::ZERO)), + .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"); @@ -3292,8 +3292,8 @@ mod tests { .with_child( Node::new("colorize1") .with_prototype("corevector.colorize") - .with_input(NodePort::geometry("shape")) - .with_input(NodePort::color("fill", Color::rgb(1.0, 0.0, 0.0))) + .with_input(Port::geometry("shape")) + .with_input(Port::color("fill", Color::rgb(1.0, 0.0, 0.0))) ) .with_rendered_child("colorize1"); @@ -3321,13 +3321,13 @@ mod tests { .with_child( Node::new("colorize1") .with_prototype("corevector.colorize") - .with_input(NodePort::geometry("shape")) + .with_input(Port::geometry("shape")) ) .with_child( Node::new("translate1") .with_prototype("corevector.translate") - .with_input(NodePort::geometry("shape")) - .with_input(NodePort::point("translate", Point::new(10.0, 10.0))) + .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"); @@ -3349,9 +3349,9 @@ mod tests { .with_child( Node::new("ellipse1") .with_prototype("corevector.ellipse") - .with_input(NodePort::point("position", Point::ZERO)) - .with_input(NodePort::float("width", 100.0)) - .with_input(NodePort::float("height", 100.0)) + .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"); @@ -3373,7 +3373,7 @@ mod tests { .with_child( Node::new("my_colorize_node") .with_prototype("corevector.colorize") - .with_input(NodePort::geometry("shape")) + .with_input(Port::geometry("shape")) ) .with_rendered_child("my_colorize_node"); @@ -3396,18 +3396,18 @@ mod tests { .with_child( Node::new("sample1") .with_prototype("math.sample") - .with_input(NodePort::int("amount", 5)) - .with_input(NodePort::float("start", 0.0)) - .with_input(NodePort::float("end", 255.0)), + .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(NodePort::float("red", 0.0)) - .with_input(NodePort::float("green", 0.0)) - .with_input(NodePort::float("blue", 0.0)) - .with_input(NodePort::float("alpha", 255.0)) - .with_input(NodePort::float("range", 255.0)), + .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"); @@ -3475,19 +3475,19 @@ mod tests { .with_child( Node::new("sample1") .with_prototype("math.sample") - .with_input(NodePort::int("amount", 3)) - .with_input(NodePort::float("start", 0.0)) - .with_input(NodePort::float("end", 255.0)) + .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(NodePort::float("red", 0.0)) - .with_input(NodePort::float("green", 0.0)) - .with_input(NodePort::float("blue", 0.0)) - .with_input(NodePort::float("alpha", 255.0)) - .with_input(NodePort::float("range", 255.0)) + .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")) From a0f97f7039dc3b3c016b6fc6659b108592f551f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 20:05:23 +0000 Subject: [PATCH 100/100] Rename test_port_and_context to test_platform_and_context https://claude.ai/code/session_01MVWg5jAgGpHUSLpY2cjrKW --- crates/nodebox-desktop/src/eval.rs | 80 +++++++++---------- .../tests/cancellation_tests.rs | 14 ++-- crates/nodebox-desktop/tests/file_tests.rs | 14 ++-- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/crates/nodebox-desktop/src/eval.rs b/crates/nodebox-desktop/src/eval.rs index f901d938..bc59fd57 100644 --- a/crates/nodebox-desktop/src/eval.rs +++ b/crates/nodebox-desktop/src/eval.rs @@ -2274,7 +2274,7 @@ mod tests { use nodebox_core::platform::{TestPlatform, ProjectContext}; /// Create a test platform and project context for evaluation tests. - fn test_port_and_context() -> (Arc, ProjectContext) { + fn test_platform_and_context() -> (Arc, ProjectContext) { (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) } @@ -2291,7 +2291,7 @@ mod tests { ) .with_rendered_child("ellipse1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2322,7 +2322,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "colorize1", "shape")) .with_rendered_child("colorize1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2361,7 +2361,7 @@ mod tests { .with_connection(Connection::new("rect1", "merge1", "shapes")) .with_rendered_child("merge1"); - let (port, ctx) = test_port_and_context(); + 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); @@ -2380,7 +2380,7 @@ mod tests { ) .with_rendered_child("rect1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2402,7 +2402,7 @@ mod tests { ) .with_rendered_child("line1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2425,7 +2425,7 @@ mod tests { ) .with_rendered_child("polygon1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2449,7 +2449,7 @@ mod tests { ) .with_rendered_child("star1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2474,7 +2474,7 @@ mod tests { ) .with_rendered_child("arc1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); } @@ -2499,7 +2499,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "translate1", "shape")) .with_rendered_child("translate1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2533,7 +2533,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "scale1", "shape")) .with_rendered_child("scale1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2567,7 +2567,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "copy1", "shape")) .with_rendered_child("copy1"); - let (port, ctx) = test_port_and_context(); + 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); @@ -2576,7 +2576,7 @@ mod tests { #[test] fn test_evaluate_empty_network() { let library = NodeLibrary::new("test"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2594,7 +2594,7 @@ mod tests { ); // No rendered_child set - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2614,7 +2614,7 @@ mod tests { .with_rendered_child("colorize1"); // Should handle missing input gracefully - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2630,7 +2630,7 @@ mod tests { .with_rendered_child("unknown1"); // Should handle unknown node type gracefully - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(paths.is_empty()); } @@ -2655,7 +2655,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "resample1", "shape")) .with_rendered_child("resample1"); - let (port, ctx) = test_port_and_context(); + 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 @@ -2685,7 +2685,7 @@ mod tests { .with_connection(Connection::new("grid1", "connect1", "points")) .with_rendered_child("connect1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); } @@ -2709,7 +2709,7 @@ mod tests { ) .with_rendered_child("ellipse1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2736,7 +2736,7 @@ mod tests { ) .with_rendered_child("rect1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2762,7 +2762,7 @@ mod tests { ) .with_rendered_child("rect1"); - let (port, ctx) = test_port_and_context(); + 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 @@ -2783,7 +2783,7 @@ mod tests { ) .with_rendered_child("polygon1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2809,7 +2809,7 @@ mod tests { ) .with_rendered_child("star1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2838,7 +2838,7 @@ mod tests { ) .with_rendered_child("arc1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2874,7 +2874,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "copy1", "shape")) .with_rendered_child("copy1"); - let (port, ctx) = test_port_and_context(); + 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"); @@ -2912,7 +2912,7 @@ mod tests { .with_connection(Connection::new("grid1", "connect1", "points")) .with_rendered_child("connect1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -2946,7 +2946,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "wiggle1", "shape")) .with_rendered_child("wiggle1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert!(!paths.is_empty(), "Wiggle should produce output"); } @@ -2976,7 +2976,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "fit1", "shape")) .with_rendered_child("fit1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!(paths.len(), 1); @@ -3079,7 +3079,7 @@ mod tests { .with_connection(Connection::new("polygon1", "combine1", "list3")) .with_rendered_child("combine1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( @@ -3150,7 +3150,7 @@ mod tests { .with_connection(Connection::new("colorize3", "combine1", "list3")) .with_rendered_child("combine1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( @@ -3188,7 +3188,7 @@ mod tests { .with_connection(Connection::new("rect1", "colorize1", "shape")) .with_rendered_child("colorize1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, _errors) = evaluate_network(&library, &port, &ctx); assert_eq!( @@ -3229,7 +3229,7 @@ mod tests { .with_connection(Connection::new("ellipse1", "combine1", "list2")) .with_rendered_child("combine1"); - let (port, ctx) = test_port_and_context(); + 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 @@ -3268,7 +3268,7 @@ mod tests { .with_connection(Connection::new("grid1", "rect1", "position")) .with_rendered_child("rect1"); - let (port, ctx) = test_port_and_context(); + 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! @@ -3297,7 +3297,7 @@ mod tests { ) .with_rendered_child("colorize1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); // Should have no paths output @@ -3332,7 +3332,7 @@ mod tests { .with_connection(Connection::new("colorize1", "translate1", "shape")) .with_rendered_child("translate1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); // Should have no output @@ -3355,7 +3355,7 @@ mod tests { ) .with_rendered_child("ellipse1"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (paths, _output, errors) = evaluate_network(&library, &port, &ctx); // Should have output @@ -3377,7 +3377,7 @@ mod tests { ) .with_rendered_child("my_colorize_node"); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let (_paths, _output, errors) = evaluate_network(&library, &port, &ctx); assert!(!errors.is_empty(), "Should have an error"); @@ -3412,7 +3412,7 @@ mod tests { .with_connection(Connection::new("sample1", "rgb1", "red")) .with_rendered_child("rgb1"); - let (port, ctx) = test_port_and_context(); + 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); @@ -3442,7 +3442,7 @@ mod tests { ) .with_rendered_child("ellipse1"); - let (port, ctx) = test_port_and_context(); + 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"); @@ -3493,7 +3493,7 @@ mod tests { .with_connection(Connection::new("sample1", "rgb_color1", "red")) .with_rendered_child("rgb_color1"); - let (port, ctx) = test_port_and_context(); + 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 { diff --git a/crates/nodebox-desktop/tests/cancellation_tests.rs b/crates/nodebox-desktop/tests/cancellation_tests.rs index 3e82f38a..d8e1f3db 100644 --- a/crates/nodebox-desktop/tests/cancellation_tests.rs +++ b/crates/nodebox-desktop/tests/cancellation_tests.rs @@ -11,7 +11,7 @@ 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_port_and_context() -> (Arc, ProjectContext) { +fn test_platform_and_context() -> (Arc, ProjectContext) { (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) } @@ -71,7 +71,7 @@ fn test_evaluation_completes_without_cancellation() { let token = CancellationToken::new(); let mut cache: HashMap = HashMap::new(); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); match outcome { @@ -94,7 +94,7 @@ fn test_evaluation_cancelled_immediately() { // Cancel immediately token.cancel(); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let outcome = evaluate_network_cancellable(&library, &token, &mut cache, &port, &ctx); match outcome { @@ -120,7 +120,7 @@ fn test_cache_preserved_after_cancellation() { token_clone.cancel(); }); - let (port, ctx) = test_port_and_context(); + 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 @@ -143,7 +143,7 @@ fn test_cache_reused_after_cancellation() { // First render - complete fully let token1 = CancellationToken::new(); - let (port, ctx) = test_port_and_context(); + let (port, ctx) = test_platform_and_context(); let outcome1 = evaluate_network_cancellable(&library, &token1, &mut cache, &port, &ctx); match outcome1 { @@ -220,7 +220,7 @@ fn test_multiple_rapid_cancellations() { token.cancel(); } - let (port, ctx) = test_port_and_context(); + 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 @@ -240,7 +240,7 @@ fn test_empty_network_not_affected_by_cancellation() { token.cancel(); // Pre-cancel - let (port, ctx) = test_port_and_context(); + 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) diff --git a/crates/nodebox-desktop/tests/file_tests.rs b/crates/nodebox-desktop/tests/file_tests.rs index 93a57ae2..9724a7ce 100644 --- a/crates/nodebox-desktop/tests/file_tests.rs +++ b/crates/nodebox-desktop/tests/file_tests.rs @@ -14,7 +14,7 @@ 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_port_and_context() -> (Arc, ProjectContext) { +fn test_platform_and_context() -> (Arc, ProjectContext) { (Arc::new(TestPlatform::new()), ProjectContext::new_unsaved()) } @@ -167,19 +167,19 @@ fn test_evaluate_primitives() { let mut test_library = library.clone(); test_library.root.rendered_child = Some("rect1".to_string()); - let (port, ctx) = test_port_and_context(); + 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_port_and_context(); + 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_port_and_context(); + 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"); } @@ -189,7 +189,7 @@ fn test_evaluate_primitives_full() { let library = create_primitives_library(); // The rendered child is "combine1" which uses list.combine - let (port, ctx) = test_port_and_context(); + 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) @@ -209,7 +209,7 @@ fn test_evaluate_colorized_primitives() { // Test colorized rect (colorize1 <- rect1) test_library.root.rendered_child = Some("colorize1".to_string()); - let (port, ctx) = test_port_and_context(); + 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"); @@ -228,7 +228,7 @@ fn test_primitives_shapes_at_different_positions() { // Evaluate rect1 alone let mut test_library = library.clone(); test_library.root.rendered_child = Some("rect1".to_string()); - let (port, ctx) = test_port_and_context(); + 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();