diff --git a/Cargo.lock b/Cargo.lock index ea3325f..dba0bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,27 +1172,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "femtovg" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a125295d4de2b2473e731c4612599ba3cdf7e05af6bba16f324ba8ffbf093436" -dependencies = [ - "bitflags 2.10.0", - "bytemuck", - "fnv", - "glow", - "image", - "imgref", - "itertools 0.14.0", - "log", - "rgb", - "slotmap", - "ttf-parser 0.25.1", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "field-offset" version = "0.3.6" @@ -1242,12 +1221,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -1705,8 +1678,9 @@ dependencies = [ "glutin", "i-slint-common", "i-slint-core", - "i-slint-renderer-femtovg", + "i-slint-renderer-skia", "input", + "memmap2", "nix", "raw-window-handle", "xkbcommon", @@ -1723,7 +1697,6 @@ dependencies = [ "i-slint-common", "i-slint-core", "i-slint-core-macros", - "i-slint-renderer-femtovg", ] [[package]] @@ -1751,7 +1724,6 @@ dependencies = [ "i-slint-common", "i-slint-core", "i-slint-core-macros", - "i-slint-renderer-femtovg", "i-slint-renderer-skia", "lyon_path", "muda", @@ -1868,28 +1840,6 @@ dependencies = [ "syn", ] -[[package]] -name = "i-slint-renderer-femtovg" -version = "1.16.0" -source = "git+https://github.com/slint-ui/slint#19be1e3b959606717c90f68bc6b8ac9267eca138" -dependencies = [ - "cfg-if", - "const-field-offset", - "derive_more", - "femtovg", - "glow", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "imgref", - "lyon_path", - "pin-weak", - "rgb", - "ttf-parser 0.25.1", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "i-slint-renderer-skia" version = "1.16.0" @@ -3938,7 +3888,6 @@ dependencies = [ "i-slint-common", "i-slint-core", "i-slint-core-macros", - "i-slint-renderer-femtovg", "i-slint-renderer-software", "num-traits", "once_cell", @@ -4107,6 +4056,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "as-raw-xcb-connection", "bytemuck", + "drm", "fastrand", "js-sys", "memmap2", diff --git a/Cargo.toml b/Cargo.toml index 5437e9c..5f2a8b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,6 @@ members = [ ] [workspace.dependencies] -slint = { git = "https://github.com/slint-ui/slint", default-features = false, features = ["std", "compat-1-2", "backend-winit", "renderer-femtovg"] } +slint = { git = "https://github.com/slint-ui/slint", default-features = false, features = ["std", "compat-1-2", "backend-winit", "renderer-skia"] } slint-build = { git = "https://github.com/slint-ui/slint" } slint-node-editor = { path = "." } diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md new file mode 100644 index 0000000..89f1c1e --- /dev/null +++ b/REFACTOR_PLAN.md @@ -0,0 +1,220 @@ +# Global Callbacks Refactoring Plan + +## Goal + +Remove all callback boilerplate from examples. Examples should only need: + +```rust +let window = MainWindow::new().unwrap(); +let ctrl = NodeEditorController::new(); +ctrl.wire_to_window(&window); // ONE LINE +window.run().unwrap(); +``` + +## Current Problems + +1. **Examples must define callbacks**: Each example's MainWindow must declare callbacks like `compute-link-path` +2. **Examples must forward callbacks**: Each example must wire NodeEditor callbacks to MainWindow callbacks +3. **Nodes don't update during drag**: Links don't follow nodes because geometry-version doesn't increment during drag +4. **Too much boilerplate**: Minimal example has ~30 lines of callback wiring + +## Solution Architecture + +### Slint Side Changes + +#### 1. Create `NodeEditorComputations` Global + +Location: `node-editor-building-blocks.slint` + +```slint +/// Computational callbacks wired by Rust - examples don't touch these! +export global NodeEditorComputations { + // Link path computation + pure callback compute-link-path(int, int, int, float, float, float) -> string; + + // Viewport updates + callback viewport-changed(float, float, float); + + // Selection checking + pure callback is-node-selected(int, int) -> bool; + pure callback is-link-selected(int, int) -> bool; + + // Other computational callbacks as needed +} +``` + +#### 2. Update NodeEditor to Call Global + +Location: `node-editor.slint` + +**BEFORE:** + +```slint +export component NodeEditor { + pure callback compute-link-path(...) -> string; + + for link in links: Link { + path-commands: root.compute-link-path(...) // Calls own callback + } +} +``` + +**AFTER:** + +```slint +export component NodeEditor { + // NO callbacks defined here! + + for link in links: Link { + path-commands: NodeEditorComputations.compute-link-path(...) // Calls global + } +} +``` + +#### 3. Simplify Examples + +Location: `examples/minimal/ui/minimal.slint` + +**BEFORE:** + +```slint +export component MainWindow { + callback update-viewport(...); + pure callback compute-link-path(...) -> string; + + editor := NodeEditor { + compute-link-path(start, end, ver, z, px, py) => { + root.compute-link-path(start, end, ver, z, px, py) + } + viewport-changed => { + root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); + } + } +} +``` + +**AFTER:** + +```slint +export component MainWindow { + // NO callbacks! + + editor := NodeEditor { + // NO callback wiring! + for data in root.nodes: SimpleNode { ... } + } +} +``` + +### Rust Side Changes + +#### 4. Add `wire_to_window()` Method + +Location: `src/controller.rs` + +```rust +impl NodeEditorController { + /// Wire all callbacks in one call - examples use this + pub fn wire_to_window(&self, window: &W) { + // Wire GeometryCallbacks + self.wire_geometry_callbacks(window); + + // Wire NodeEditorComputations + self.wire_computations(window); + } + + fn wire_geometry_callbacks(&self, window: &W) { + let geometry_callbacks = window.global::(); + + geometry_callbacks.on_report_node_rect({ + let ctrl = self.clone(); + move |id, x, y, w, h| ctrl.handle_node_rect(id, x, y, w, h) + }); + + geometry_callbacks.on_report_pin_position({ + let ctrl = self.clone(); + move |pid, nid, ptype, x, y| ctrl.handle_pin_position(pid, nid, ptype, x, y) + }); + + // ... other geometry callbacks + } + + fn wire_computations(&self, window: &W) { + let computations = window.global::(); + + computations.on_compute_link_path(self.compute_link_path_callback()); + + computations.on_viewport_changed({ + let ctrl = self.clone(); + move |z, px, py| ctrl.set_viewport(z, px, py) + }); + + // ... other computational callbacks + } +} +``` + +## Implementation Steps + +1. ✅ **TEST**: Verify Slint supports `pure callback` on globals (CONFIRMED) +2. ⏳ **Create NodeEditorComputations global** in `node-editor-building-blocks.slint` +3. ⏳ **Update NodeEditor** to call `NodeEditorComputations` instead of own callbacks +4. ⏳ **Simplify minimal.slint** - remove all callbacks +5. ⏳ **Update minimal/src/main.rs** - use `ctrl.wire_to_window(&window)` +6. ⏳ **Test minimal example** compiles and runs +7. ⏳ **Fix drag updates** - ensure geometry-version increments during drag +8. ⏳ **Update other examples** (advanced, zoom-stress-test, etc.) +9. ⏳ **Update documentation** + +## Current Issues to Fix + +### Issue 1: Links Don't Update During Drag + +**Problem**: `changed drag-offset-x/y` handlers don't fire because they're computed properties +**Solution**: Already implemented - BaseNode's TouchArea moved handler calls: + +```slint +GeometryVersion.version += 1; +GeometryCallbacks.report-node-rect(node-id, world-x + current-drag-offset-x, ...); +``` + +### Issue 2: There are leftover broken changed handlers + +**Location**: `node-editor-building-blocks.slint:452` +**Error**: `changed DragState.drag-offset-x =>` (invalid syntax) +**Solution**: Remove these - they were from a failed attempt + +## Benefits After Refactoring + +1. ✅ **Zero callback boilerplate** in examples +2. ✅ **One-line Rust wiring**: `ctrl.wire_to_window(&window)` +3. ✅ **Automatic geometry updates**: Links follow nodes during drag +4. ✅ **Consistent architecture**: All globals work the same way +5. ✅ **Easy to extend**: New examples just copy the pattern + +## Files Modified + +### Library Files + +- `node-editor-building-blocks.slint` - Add NodeEditorComputations global +- `node-editor.slint` - Remove callbacks, call globals instead +- `src/controller.rs` - Add wire_to_window() method +- `src/lib.rs` - Export new types if needed + +### Example Files + +- `examples/minimal/ui/minimal.slint` - Remove all callbacks +- `examples/minimal/src/main.rs` - Simplify to one-line wiring +- Other examples: Update similarly + +## Testing Checklist + +- [ ] minimal example compiles +- [ ] minimal example runs +- [ ] Can drag nodes +- [ ] Links follow nodes during drag +- [ ] Links update when panning +- [ ] Selection works +- [ ] Double-click works +- [ ] Link creation works +- [ ] All tests pass diff --git a/SIMPLIFICATION_PLAN.md b/SIMPLIFICATION_PLAN.md new file mode 100644 index 0000000..f63c3ba --- /dev/null +++ b/SIMPLIFICATION_PLAN.md @@ -0,0 +1,145 @@ +# Node Editor Simplification Plan + +Goal: Eliminate all node editor mechanics from examples, leaving only domain-specific logic. + +## Phase 1: Auto-Grid Management + +**Goal:** Grid updates happen automatically inside the library + +**Changes:** + +1. NodeEditor internally watches `changed zoom`, `changed pan-x`, `changed pan-y` +2. Calls internal grid generation function +3. Updates own `grid-commands` property +4. Remove from examples: `on_request_grid_update`, `on_viewport_changed` (for grid), `generate_grid()` calls + +**Impact:** Removes 2-3 callbacks from every example + +--- + +## Phase 2: Default Link Path Computation + +**Goal:** Standard bezier path is automatic + +**Changes:** + +1. Add `use-default-link-paths: bool` property to NodeEditor (default: true) +2. When true, NodeEditor internally sets own `compute-link-path` callback +3. Uses library's `generate_bezier_path()` with standard offset +4. Examples with custom paths (orthogonal, animated) set property to false and provide callback +5. Remove from simple examples: `on_compute_link_path` entirely + +**Impact:** Removes 1 callback from minimal, sugiyama, zoom-stress-test + +--- + +## Phase 3: Default Pin Hit Testing + +**Goal:** Standard circular pin hit testing is automatic + +**Changes:** + +1. Add `use-default-pin-hit-test: bool` property to NodeEditor (default: true) +2. When true, internally wires `compute-pin-at` to cache lookup +3. Examples with custom hit areas (rectangular pins, etc.) override +4. Remove from simple examples: `on_compute_pin_at` + +**Impact:** Removes 1 callback from examples with pin dragging + +--- + +## Phase 4: Default Link Preview Path + +**Goal:** Link creation preview uses standard bezier automatically + +**Changes:** + +1. Add `use-default-link-preview: bool` property to NodeEditor (default: true) +2. When true, internally wires `compute-link-preview-path` +3. Custom examples (animated, custom shapes) override +4. Remove from simple examples: `on_compute_link-preview-path` + +**Impact:** Removes 1 callback from examples with link creation + +--- + +## Phase 5: Geometry Tracking Consolidation ✓ COMPLETED + +**Goal:** NodeEditorSetup (or new helper) handles all geometry callbacks automatically + +**Changes:** + +1. ✓ Pin component now supports `parent-offset-x/y` properties for wrapped pins + - Wrapped pins (e.g., ValidatedPin) can pass their offset to report correct position + - Pin calculates `center-x/y` including parent offset for geometry reporting + - Examples with wrapped pins set these properties to the wrapper's position +2. ✓ `wire_node_editor!` macro automatically wires geometry callbacks + - `on_report_node_rect` and `on_report_pin_position` to controller + - `on_start_node_drag` and `on_end_node_drag` for node movement + - `on_compute_link_path` for link path generation +3. ✓ All examples converted: minimal, sugiyama, animated-links, custom-shapes, zoom-stress-test, pin-compatibility, advanced + +**Impact:** Removes 5 explicit callback setups (2 geometry + 2 drag + 1 path) from all examples, enables pin wrapping without coordinate math + +--- + +## Phase 6: Node Drag Consolidation + +**Goal:** Drag start/end handled by setup helper + +**Changes:** + +1. NodeEditorSetup already has drag callback +2. Extend `wire_node_editor!` to also wire `on_node_drag_started` +3. Move model update logic into NodeEditorSetup (already there) +4. Remove from examples: manual `on_node_drag_started` and `on_node_drag_ended` setup + +**Impact:** Removes 2 callback setups, examples only provide model update closure to setup + +--- + +## Phase 7: Optional Default Hit Testing for Links/Boxes + +**Goal:** Standard link and box selection hit testing automatic + +**Changes:** + +1. Add properties: `use-default-link-hit-test`, `use-default-box-selection` +2. When true, wire callbacks internally +3. Advanced example overrides for custom logic +4. Remove from simple examples: `on_compute_link_at`, `on_compute_box_selection` + +**Impact:** Removes 2 callbacks from examples with selection + +--- + +## Target: Minimal Example After All Phases + +```rust +let nodes = Rc::new(VecModel::from([...])); +window.set_nodes(ModelRc::from(nodes.clone())); +window.set_links(ModelRc::from([...])); + +let setup = NodeEditorSetup::new(move |node_id, dx, dy| { + // Update node position in model +}); + +wire_node_editor!(window, setup); +window.run().unwrap(); +``` + +**~10 lines total** for a working node editor with connections, dragging, zoom/pan, grid. + +--- + +## Implementation Order + +Priority by impact/simplicity ratio: + +1. ✅ **Phase 5** (Geometry + Drag + Link Path) - COMPLETED, affects all examples, 5 callbacks removed +2. **Phase 1** (Grid) - Easy, affects all examples, 2-3 callbacks removed +3. **Phase 2** (Link paths) - ~~Medium~~ DEFERRED (already handled by Phase 5's wire_node_editor!) +4. **Phase 6** (Drag) - ~~Easy~~ DEFERRED (already handled by Phase 5's wire_node_editor!) +5. **Phase 3** (Pin hit test) - Easy, affects some examples +6. **Phase 4** (Link preview) - Easy, affects some examples +7. **Phase 7** (Advanced hit testing) - Last, only benefits complex examples diff --git a/examples/advanced/src/main.rs b/examples/advanced/src/main.rs index bf09bc2..65092fe 100644 --- a/examples/advanced/src/main.rs +++ b/examples/advanced/src/main.rs @@ -5,8 +5,9 @@ use slint::{Color, Model, ModelRc, SharedString, VecModel}; use slint_node_editor::{ - GraphLogic, LinkModel, MovableNode, NodeEditorController, SelectionManager, + GraphLogic, LinkModel, MovableNode, NodeEditorSetup, SelectionManager, BasicLinkValidator, NoDuplicatesValidator, CompositeValidator, LinkValidator, ValidationResult, + wire_node_editor, }; use std::cell::RefCell; use std::rc::Rc; @@ -168,7 +169,6 @@ fn build_minimap_nodes( fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); let selection_manager = Rc::new(RefCell::new(SelectionManager::new())); let link_selection_manager = Rc::new(RefCell::new(SelectionManager::new())); @@ -247,8 +247,20 @@ fn main() { let filter_width = filter_node_constants.get_base_width(); let filter_height = filter_node_constants.get_base_height(); + // Create setup with model update logic for both node types + let setup = NodeEditorSetup::new({ + let nodes = nodes.clone(); + let filter_nodes = filter_nodes.clone(); + let sm = selection_manager.clone(); + move |_node_id, delta_x, delta_y| { + let sm = sm.borrow(); + GraphLogic::commit_drag(&nodes, &sm, delta_x, delta_y); + GraphLogic::commit_drag(&filter_nodes, &sm, delta_x, delta_y); + } + }); + // Configure controller - ctrl.set_grid_spacing(node_constants.get_grid_spacing()); + setup.controller().set_grid_spacing(node_constants.get_grid_spacing()); // Create selection models let selected_node_ids: Rc> = Rc::new(VecModel::default()); @@ -268,41 +280,11 @@ fn main() { // === Computation Callbacks === - window.on_request_grid_update({ - let ctrl = ctrl.clone(); - let w = window.as_weak(); - move || { - if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); - } - } - }); - - window.on_pin_position_changed({ - let ctrl = ctrl.clone(); - move |pin_id, node_id, pin_type, rel_x, rel_y| { - ctrl.handle_pin_position(pin_id, node_id, pin_type, rel_x, rel_y); - } - }); - - window.on_node_rect_changed({ - let ctrl = ctrl.clone(); - move |id, x, y, width, height| { - ctrl.handle_node_rect(id, x, y, width, height); - } - }); - - window.on_compute_pin_at({ - let ctrl = ctrl.clone(); - let w = window.as_weak(); - move |x, y| { - let w = match w.upgrade() { Some(w) => w, None => return 0 }; - ctrl.cache().borrow().find_pin_at(x as f32, y as f32, w.get_pin_hit_radius() as f32) - } - }); + // Wire standard callbacks with one macro call + wire_node_editor!(window, setup); window.on_compute_link_at({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let links = links.clone(); let w = window.as_weak(); move |x, y| { @@ -314,15 +296,8 @@ fn main() { } }); - window.on_compute_box_selection({ - let ctrl = ctrl.clone(); - move |x, y, w, h| { - ModelRc::from(Rc::new(VecModel::from(ctrl.cache().borrow().nodes_in_selection_box(x as f32, y as f32, w as f32, h as f32)))) - } - }); - window.on_compute_link_box_selection({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let links = links.clone(); move |x, y, w, h| { let cache = ctrl.cache(); @@ -332,51 +307,38 @@ fn main() { } }); - window.on_compute_link_path({ - let ctrl = ctrl.clone(); - let w = window.as_weak(); - move |start_pin, end_pin, _version, _zoom: f32, _pan_x: f32, _pan_y: f32| { - let w = match w.upgrade() { Some(w) => w, None => return SharedString::default() }; - ctrl.cache().borrow() - .compute_link_path(start_pin, end_pin, w.get_zoom(), w.get_bezier_min_offset()) - .unwrap_or_default() - .into() - } - }); - - let window_for_preview = window.as_weak(); - window.on_compute_link_preview_path(move |start_x, start_y, end_x, end_y| { - let w = match window_for_preview.upgrade() { Some(w) => w, None => return "".into() }; - slint_node_editor::generate_bezier_path(start_x as f32, start_y as f32, end_x as f32, end_y as f32, w.get_zoom(), w.get_bezier_min_offset()).into() - }); - - // === Selection Checking Callbacks === + // === Selection Checking Callbacks (now on NodeEditorComputations global) === let sm_check = selection_manager.clone(); - window.on_is_node_selected(move |id| sm_check.borrow().contains(id)); + window.global::().on_is_node_selected(move |id, _version| sm_check.borrow().contains(id)); let lsm_check = link_selection_manager.clone(); - window.on_is_link_selected(move |id| lsm_check.borrow().contains(id)); + window.global::().on_is_link_selected(move |id, _version| lsm_check.borrow().contains(id)); - // === Selection Manipulation Callbacks === + // === Selection Notification Callbacks === + // NodeEditor handles basic selection logic internally. These callbacks allow the app + // to perform additional actions (like clearing link selection when a node is selected). - let sn_ids = selected_node_ids.clone(); let sl_ids = selected_link_ids.clone(); - let sm_select = selection_manager.clone(); let lsm_select = link_selection_manager.clone(); - let window_for_select_node = window.as_weak(); - window.on_select_node(move |node_id, shift| { + let sm_for_shift = selection_manager.clone(); + let sn_ids_shift = selected_node_ids.clone(); + let window_for_node_selected = window.as_weak(); + window.on_node_selected(move |node_id, shift| { + // Clear link selection when a node is selected lsm_select.borrow_mut().clear(); lsm_select.borrow().sync_to_model(&*sl_ids); - let mut sm = sm_select.borrow_mut(); - sm.handle_interaction(node_id, shift); - sm.sync_to_model(&*sn_ids); - - if let Some(w) = window_for_select_node.upgrade() { - w.set_selection_version(w.get_selection_version() + 1); - w.invoke_selection_changed(); + if shift { + // For shift-select, NodeEditor doesn't modify selection, app must handle it + let mut sm = sm_for_shift.borrow_mut(); + sm.handle_interaction(node_id, true); + sm.sync_to_model(&*sn_ids_shift); + if let Some(w) = window_for_node_selected.upgrade() { + w.set_selection_version(w.get_selection_version() + 1); + } } + // For non-shift, NodeEditor already updated selection and version }); let sn_ids_l = selected_node_ids.clone(); @@ -402,41 +364,29 @@ fn main() { } }); - let sn_ids_c = selected_node_ids.clone(); let sl_ids_c = selected_link_ids.clone(); - let sm_clear = selection_manager.clone(); let lsm_clear = link_selection_manager.clone(); - let window_for_clear = window.as_weak(); - window.on_clear_selection(move || { - sm_clear.borrow_mut().clear(); - sm_clear.borrow().sync_to_model(&*sn_ids_c); + window.on_selection_cleared(move || { + // NodeEditor already cleared node selection, just clear link selection lsm_clear.borrow_mut().clear(); lsm_clear.borrow().sync_to_model(&*sl_ids_c); - - if let Some(w) = window_for_clear.upgrade() { - w.set_selection_version(w.get_selection_version() + 1); - w.invoke_selection_changed(); - } }); + // Override global selection sync to use our SelectionManager (NodeEditor increments selection-version) let sm_sync = selection_manager.clone(); - let window_for_sync = window.as_weak(); - window.on_sync_selection_to_nodes(move |ids_model| { + window.global::().on_sync_selection_to_nodes(move |ids_model| { sm_sync.borrow_mut().sync_from_model(&ids_model); - if let Some(w) = window_for_sync.upgrade() { w.set_selection_version(w.get_selection_version() + 1); } }); let lsm_sync = link_selection_manager.clone(); - let window_for_sync_links = window.as_weak(); window.on_sync_selection_to_links(move |ids_model| { lsm_sync.borrow_mut().sync_from_model(&ids_model); - if let Some(w) = window_for_sync_links.upgrade() { w.set_selection_version(w.get_selection_version() + 1); } }); // === Event Callbacks === window.on_create_link({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let links = links.clone(); let next_link_id = next_link_id.clone(); let color_index = color_index.clone(); @@ -479,29 +429,8 @@ fn main() { } }); - window.on_update_viewport({ - let ctrl = ctrl.clone(); - let w = window.as_weak(); - move |zoom, pan_x, pan_y| { - let w = match w.upgrade() { Some(w) => w, None => return }; - ctrl.set_zoom(zoom); - - // Update grid - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - }); - - let nodes_for_drag = nodes.clone(); - let filter_nodes_for_drag = filter_nodes.clone(); - let sm_drag = selection_manager.clone(); - window.on_commit_drag(move |dx, dy| { - let sm = sm_drag.borrow(); - GraphLogic::commit_drag(&nodes_for_drag, &sm, dx, dy); - GraphLogic::commit_drag(&filter_nodes_for_drag, &sm, dx, dy); - }); - window.on_delete_selected_nodes({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let nodes = nodes.clone(); let filter_nodes = filter_nodes.clone(); let links = links.clone(); diff --git a/examples/advanced/ui/filter_node.slint b/examples/advanced/ui/filter_node.slint index c63eaa1..af72d79 100644 --- a/examples/advanced/ui/filter_node.slint +++ b/examples/advanced/ui/filter_node.slint @@ -35,8 +35,8 @@ export struct FilterNodeData { export component ControlPin inherits Pin { in-out property pulse-state: false; border-radius: 4px; - width: pulse-state ? 24px * self.zoom : 10px * self.zoom; // 20% bigger when pulsed (20 * 1.2 = 24) - height: pulse-state ? 24px * self.zoom : 10px * self.zoom; + width: pulse-state ? 24px : 10px; // 20% bigger when pulsed (20 * 1.2 = 24) + height: pulse-state ? 24px : 10px; background: #FFC107; border-color: #FF9800; @@ -77,7 +77,7 @@ export component FilterNode inherits BaseNode { // Pin dimensions (scaled by root.zoom) property base-pin-size: 12px; - property pin-size: base-pin-size * self.zoom; + property pin-size: base-pin-size; property pin-radius: pin-size / 2; property pin-margin: 4px; @@ -95,28 +95,28 @@ export component FilterNode inherits BaseNode { // Pin Y positions - centered on each content row // Account for: VerticalLayout padding + title + HorizontalLayout padding-top + row offset - property row-1-center: (content-padding + title-height + content-padding + row-height / 2) * self.zoom; - property row-2-center: (content-padding + title-height + content-padding + row-height + row-height / 2) * self.zoom; + property row-1-center: (content-padding + title-height + content-padding + row-height / 2); + property row-2-center: (content-padding + title-height + content-padding + row-height + row-height / 2); // Set dimensions - width: base-width * self.zoom; - height: base-height * self.zoom; + width: base-width; + height: base-height; // Main layout VerticalLayout { - padding: root.content-padding * root.zoom; + padding: root.content-padding; spacing: 0px; // Title bar Rectangle { - height: root.title-height * root.zoom; + height: root.title-height; background: root.selected ? #6a4a9a : #4a3d5d; - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } @@ -124,60 +124,60 @@ export component FilterNode inherits BaseNode { // Content area with left/right margins for pins HorizontalLayout { - padding-top: root.content-padding * root.zoom; + padding-top: root.content-padding; // Left pin labels column VerticalLayout { - width: pin-area-width * root.zoom; + width: pin-area-width; spacing: 0px; // Row 1: Data input label Rectangle { - height: row-height * root.zoom; + height: row-height; Text { - x: 16px * root.zoom; + x: 16px; text: "In"; color: #888; - font-size: 10px * root.zoom; + font-size: 10px; vertical-alignment: center; } } // Row 2: Control input label Rectangle { - height: row-height * root.zoom; + height: row-height; Text { - x: 16px * root.zoom; + x: 16px; text: "Ctrl"; color: #888; - font-size: 10px * root.zoom; + font-size: 10px; vertical-alignment: center; } } // Row 3: empty Rectangle { - height: row-height * root.zoom; + height: row-height; } } // Center content VerticalLayout { - spacing: 4px * root.zoom; + spacing: 4px; horizontal-stretch: 1; // Row 1: Filter type selector HorizontalLayout { - height: row-height * root.zoom; - spacing: 8px * root.zoom; + height: row-height; + spacing: 8px; alignment: stretch; - padding-top: 4px * root.zoom; - padding-bottom: 4px * root.zoom; + padding-top: 4px; + padding-bottom: 4px; Text { text: "Type:"; color: #aaa; - font-size: 11px * root.zoom; + font-size: 11px; vertical-alignment: center; } @@ -193,34 +193,34 @@ export component FilterNode inherits BaseNode { // Row 2: Status row HorizontalLayout { - height: row-height * root.zoom; - spacing: 12px * root.zoom; + height: row-height; + spacing: 12px; alignment: start; - padding-top: 4px * root.zoom; - padding-bottom: 4px * root.zoom; + padding-top: 4px; + padding-bottom: 4px; Text { text: root.enabled ? "Active" : "Bypassed"; color: root.enabled ? #8f8 : #f88; - font-size: 12px * root.zoom; + font-size: 12px; vertical-alignment: center; } Text { text: "Count: " + root.processed-count; color: #888; - font-size: 11px * root.zoom; + font-size: 11px; vertical-alignment: center; } } // Row 3: Button row HorizontalLayout { - height: row-height * root.zoom; - spacing: 8px * root.zoom; + height: row-height; + spacing: 8px; alignment: start; - padding-top: 2px * root.zoom; - padding-bottom: 2px * root.zoom; + padding-top: 2px; + padding-bottom: 2px; Button { text: root.enabled ? "Bypass" : "Enable"; @@ -240,18 +240,18 @@ export component FilterNode inherits BaseNode { // Right pin labels column VerticalLayout { - width: pin-area-width * root.zoom; + width: pin-area-width; spacing: 0px; // Row 1: Data output label Rectangle { - height: row-height * root.zoom; + height: row-height; Text { x: 0px; - width: parent.width - 16px * root.zoom; + width: parent.width - 16px; text: "Out"; color: #888; - font-size: 10px * root.zoom; + font-size: 10px; vertical-alignment: center; horizontal-alignment: right; } @@ -259,102 +259,53 @@ export component FilterNode inherits BaseNode { // Rows 2-3: empty Rectangle { - height: row-height * root.zoom * 2; + height: row-height * 2; } } } } - // Report pin positions after initialization (for newly added nodes) - init => { - // Pins report themselves via their own init handlers, but we also - // report here to ensure it happens even if pin init runs before node-id is set - if (self.node-id > 0) { - // Compute pin IDs inline to ensure correct evaluation - root.pin-position-changed(root.data-input-pin-id, self.node-id, FilterPinTypes.data-input, data-input-pin.center-x, data-input-pin.center-y); - root.pin-position-changed(root.control-input-pin-id, self.node-id, FilterPinTypes.control-input, control-input-pin.center-x, control-input-pin.center-y); - root.pin-position-changed(root.data-output-pin-id, self.node-id, FilterPinTypes.data-output, data-output-pin.center-x, data-output-pin.center-y); - } - } + // Pins auto-report their positions via GeometryCallbacks global // Data Input pin (green, left side, row 1) data-input-pin := Pin { - x: root.pin-margin * root.zoom; + x: root.pin-margin; y: root.row-1-center - root.pin-radius; pin-id: root.data-input-pin-id; node-id: root.node-id; pin-type: FilterPinTypes.data-input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #4CAF50; hover-color: #66BB6A; base-size: root.base-pin-size; - drag-started(id, x, y) => { - root.pin-drag-started(id, x, y); - } - drag-moved(id, x, y) => { - root.pin-drag-moved(id, x, y); - } - drag-ended(id, x, y) => { - root.pin-drag-ended(id, x, y); - } - report-position(pin-id, node-id, pin-type, x, y) => { - root.pin-position-changed(pin-id, node-id, pin-type, x, y); - } } // Control Input pin (yellow, left side, row 2) control-input-pin := ControlPin { - x: root.pin-margin * root.zoom; + x: root.pin-margin; y: root.row-2-center - root.pin-radius; pin-id: root.control-input-pin-id; node-id: root.node-id; pin-type: FilterPinTypes.control-input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #FFC107; hover-color: #FFD54F; base-size: root.base-pin-size; - drag-started(id, x, y) => { - root.pin-drag-started(id, x, y); - } - drag-moved(id, x, y) => { - root.pin-drag-moved(id, x, y); - } - drag-ended(id, x, y) => { - root.pin-drag-ended(id, x, y); - } - report-position(pin-id, node-id, pin-type, x, y) => { - root.pin-position-changed(pin-id, node-id, pin-type, x, y); - } } // Data Output pin (blue, right side, row 1) data-output-pin := Pin { - x: parent.width - root.pin-margin * root.zoom - root.pin-size; + x: parent.width - root.pin-margin - root.pin-size; y: root.row-1-center - root.pin-radius; pin-id: root.data-output-pin-id; node-id: root.node-id; pin-type: FilterPinTypes.data-output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #2196F3; hover-color: #42A5F5; base-size: root.base-pin-size; - drag-started(id, x, y) => { - root.pin-drag-started(id, x, y); - } - drag-moved(id, x, y) => { - root.pin-drag-moved(id, x, y); - } - drag-ended(id, x, y) => { - root.pin-drag-ended(id, x, y); - } - report-position(pin-id, node-id, pin-type, x, y) => { - root.pin-position-changed(pin-id, node-id, pin-type, x, y); - } } } diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index 4d8cba9..b44ba13 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -25,6 +25,8 @@ import { BaseNode, NodeStyleDefaults, LinkData, + NodeEditorComputations, + GeometryCallbacks, } from "@slint-node-editor/node-editor.slint"; import { PinId } from "pin_encoding.slint"; import { InstructionsOverlay } from "instructions_overlay.slint"; @@ -37,8 +39,8 @@ export struct NodeData { world-y: float, } -// Re-export LinkData for Rust -export { LinkData } +// Re-export LinkData and NodeEditorComputations for Rust +export { LinkData, NodeEditorComputations, GeometryCallbacks } // Re-export NodeStyleDefaults for Rust access (as NodeConstants for backward compatibility) export global NodeConstants { @@ -80,7 +82,7 @@ component Node inherits BaseNode { in property output-pin-id: PinId.make(self.node-id, PinTypes.output); // Pin dimensions (scaled by zoom) - property pin-size: NodeConstants.pin-size * self.zoom; + property pin-size: NodeConstants.pin-size; property pin-radius: pin-size / 2; // Base dimensions (from global constants) @@ -94,87 +96,56 @@ component Node inherits BaseNode { out property output-pin-y: self.screen-y + output-pin.center-y; // Set dimensions - width: base-width * self.zoom; - height: base-height * self.zoom; + width: base-width; + height: base-height; // Title bar Rectangle { - x: 8px * root.zoom; - y: 8px * root.zoom; - width: parent.width - 16px * root.zoom; - height: 24px * root.zoom; + x: 8px; + y: 8px; + width: parent.width - 16px; + height: 24px; background: root.selected ? #4a6a9a : #3d3d3d; - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } } // Report pin positions after initialization (for newly added nodes) - init => { - if (self.node-id > 0) { - root.pin-position-changed(root.input-pin-id, self.node-id, PinTypes.input, input-pin.center-x, input-pin.center-y); - root.pin-position-changed(root.output-pin-id, self.node-id, PinTypes.output, output-pin.center-x, output-pin.center-y); - } - } + // Pins auto-report via GeometryCallbacks global now // Input pin (green) - with drag-to-link support input-pin := Pin { - x: 8px * root.zoom; - y: (8px + 24px + 8px) * root.zoom; // below title bar + x: 8px; + y: (8px + 24px + 8px); // below title bar pin-id: root.input-pin-id; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #4CAF50; hover-color: #66BB6A; base-size: NodeConstants.pin-size; - drag-started(id, x, y) => { - root.pin-drag-started(id, x, y); - } - drag-moved(id, x, y) => { - root.pin-drag-moved(id, x, y); - } - drag-ended(id, x, y) => { - root.pin-drag-ended(id, x, y); - } - report-position(pin-id, node-id, pin-type, x, y) => { - root.pin-position-changed(pin-id, node-id, pin-type, x, y); - } } // Output pin (blue) - with drag-to-link support output-pin := Pin { - x: parent.width - 8px * root.zoom - root.pin-size; - y: (8px + 24px + 8px) * root.zoom; // below title bar + x: parent.width - 8px - root.pin-size; + y: (8px + 24px + 8px); // below title bar pin-id: root.output-pin-id; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #2196F3; hover-color: #42A5F5; base-size: NodeConstants.pin-size; - drag-started(id, x, y) => { - root.pin-drag-started(id, x, y); - } - drag-moved(id, x, y) => { - root.pin-drag-moved(id, x, y); - } - drag-ended(id, x, y) => { - root.pin-drag-ended(id, x, y); - } - report-position(pin-id, node-id, pin-type, x, y) => { - root.pin-position-changed(pin-id, node-id, pin-type, x, y); - } } } @@ -199,7 +170,7 @@ export component MainWindow inherits Window { // Callbacks to Rust for state changes callback selection-changed <=> editor.selection-changed; callback commit-drag <=> editor.node-drag-ended; - callback update-viewport(float, float, float); // zoom, pan-x, pan-y (for link position updates) + // Note: viewport changes are now handled via NodeEditorComputations global callback create-link <=> editor.link-requested; callback delete-selected-nodes(); // delete all selected nodes callback add-node <=> editor.add-node-requested; @@ -213,14 +184,8 @@ export component MainWindow inherits Window { callback request-grid-update <=> editor.request-grid-update; callback compute-pin-at <=> editor.compute-pin-at; callback compute-link-at <=> editor.compute-link-at; - callback compute-box-selection <=> editor.compute-box-selection; callback compute-link-box-selection <=> editor.compute-link-box-selection; - callback compute-link-preview-path <=> editor.compute-link-preview-path; - pure callback compute-link-path <=> editor.compute-link-path; - - // Node/pin position tracking - callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; + // Note: compute-link-path, compute-link-preview-path, node-rect, and pin-position are now handled via globals // Pure function to snap a value to the grid pure public function snap-to-grid(value: float) -> float { @@ -247,16 +212,13 @@ export component MainWindow inherits Window { in-out property <[int]> selected-node-ids <=> editor.selected-node-ids; in-out property <[int]> selected-link-ids <=> editor.selected-link-ids; - // Selection manipulation callbacks (implemented by Rust) - callback select-node <=> editor.select-node; + // Selection notification callbacks (NodeEditor handles selection logic internally) + callback node-selected <=> editor.node-selected; callback select-link <=> editor.select-link; - callback clear-selection <=> editor.clear-selection; - callback sync-selection-to-nodes <=> editor.sync-selection-to-nodes; + callback selection-cleared <=> editor.selection-cleared; callback sync-selection-to-links <=> editor.sync-selection-to-links; - // Selection checking callbacks (must be pure for use in property bindings) - pure callback is-node-selected(int) -> bool; - pure callback is-link-selected(int) -> bool; + // Selection checking is now via NodeEditorComputations global // Selection version counter in-out property selection-version <=> editor.selection-version; @@ -315,6 +277,9 @@ export component MainWindow inherits Window { grid-commands <=> root.grid-commands; links <=> root.links; + // Enable link selection + has-link-selection: true; + // Set pin hit radius from globals pin-hit-radius: NodeConstants.pin-size * zoom * 0.66; link-hover-distance: 8px; @@ -333,17 +298,7 @@ export component MainWindow inherits Window { - // Forward selection checking callbacks to root (version param ignored, just for cache invalidation) - is-selected(node-id, version) => { - return root.is-node-selected(node-id); - } - is-link-selected(link-id, version) => { - return root.is-link-selected(link-id); - } - - viewport-changed => { - root.update-viewport(zoom, pan-x / 1px, pan-y / 1px); - } + // Selection checking is now handled via NodeEditorComputations global delete-selected => { root.delete-selected-nodes(); @@ -351,32 +306,15 @@ export component MainWindow inherits Window { } // Nodes - children of NodeEditor + // BaseNode handles zoom/pan/drag via globals - no manual property forwarding needed! for node-data in nodes: Node { title: node-data.title; node-id: node-data.id; - selected: editor.is-selected(node-data.id, editor.selection-version); + selected: NodeEditorComputations.is-node-selected(node-data.id, editor.selection-version); world-x: node-data.world-x * 1px; world-y: node-data.world-y * 1px; - drag-offset-x: editor.is-selected(node-data.id, editor.selection-version) ? editor.drag-offset-x : 0px; - drag-offset-y: editor.is-selected(node-data.id, editor.selection-version) ? editor.drag-offset-y : 0px; - zoom: zoom; - pan-x: pan-x; - pan-y: pan-y; - clicked(id, shift) => { - editor.handle-node-click(id, shift); - } - drag-started(id, already-selected, wx, wy) => { - editor.start-node-drag(id, already-selected, wx, wy); - } - drag-moved(id, offset-x, offset-y) => { - editor.update-node-drag(offset-x, offset-y); - } - drag-ended(id, delta-x, delta-y) => { - editor.end-node-drag(delta-x, delta-y); - } - pin-position-changed(pin-id, node-id, pin-type, x, y) => { - editor.report-pin-position(pin-id, node-id, pin-type, x, y); - } + viewport-width: editor.width; + viewport-height: editor.height; pin-drag-started(pin-id, x, y) => { editor.start-link-from-pin(pin-id, x, y); } @@ -387,42 +325,22 @@ export component MainWindow inherits Window { editor.update-link-end(x, y); editor.complete-link-creation(); } - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } } // Filter nodes - complex nodes with widgets + // BaseNode handles zoom/pan/drag via globals - no manual property forwarding needed! for filter-data in filter-nodes: FilterNode { title: filter-data.title; node-id: filter-data.id; - selected: editor.is-selected(filter-data.id, editor.selection-version); + selected: NodeEditorComputations.is-node-selected(filter-data.id, editor.selection-version); world-x: filter-data.world-x * 1px; world-y: filter-data.world-y * 1px; - drag-offset-x: editor.is-selected(filter-data.id, editor.selection-version) ? editor.drag-offset-x : 0px; - drag-offset-y: editor.is-selected(filter-data.id, editor.selection-version) ? editor.drag-offset-y : 0px; - zoom: zoom; - pan-x: pan-x; - pan-y: pan-y; + viewport-width: editor.width; + viewport-height: editor.height; filter-type-index: filter-data.filter-type-index; enabled: filter-data.enabled; processed-count: filter-data.processed-count; - clicked(id, shift) => { - editor.handle-node-click(id, shift); - } - drag-started(id, already-selected, wx, wy) => { - editor.start-node-drag(id, already-selected, wx, wy); - } - drag-moved(id, offset-x, offset-y) => { - editor.update-node-drag(offset-x, offset-y); - } - drag-ended(id, delta-x, delta-y) => { - editor.end-node-drag(delta-x, delta-y); - } - pin-position-changed(pin-id, node-id, pin-type, x, y) => { - editor.report-pin-position(pin-id, node-id, pin-type, x, y); - } pin-drag-started(pin-id, x, y) => { editor.start-link-from-pin(pin-id, x, y); } @@ -433,9 +351,6 @@ export component MainWindow inherits Window { editor.update-link-end(x, y); editor.complete-link-creation(); } - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } filter-type-changed(id, idx) => { root.filter-type-changed(id, idx); } diff --git a/examples/animated-links/src/main.rs b/examples/animated-links/src/main.rs index 6108677..a2f9c0a 100644 --- a/examples/animated-links/src/main.rs +++ b/examples/animated-links/src/main.rs @@ -1,5 +1,5 @@ use slint::{Color, Model, ModelRc, SharedString, Timer, TimerMode, VecModel}; -use slint_node_editor::NodeEditorController; +use slint_node_editor::{NodeEditorSetup, wire_node_editor}; use std::cell::RefCell; use std::rc::Rc; use std::time::Instant; @@ -11,7 +11,6 @@ const ANIMATION_DURATION: f32 = 0.5; fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); let w = window.as_weak(); // Track animation start time @@ -64,35 +63,29 @@ fn main() { Color::from_argb_u8(255, 52, 211, 153), // Green ]; - // Core callbacks - window.on_compute_link_path(ctrl.compute_link_path_callback()); - window.on_node_drag_started(ctrl.node_drag_started_callback()); - - // Pin hit testing - window.on_compute_pin_at({ - let ctrl = ctrl.clone(); - move |x, y| ctrl.cache().borrow().find_pin_at(x as f32, y as f32, 10.0) - }); - - // Link preview path generation - window.on_compute_link_preview_path({ - let ctrl = ctrl.clone(); - move |start_x, start_y, end_x, end_y| { - slint_node_editor::generate_bezier_path( - start_x as f32, - start_y as f32, - end_x as f32, - end_y as f32, - ctrl.zoom(), - 50.0, - ) - .into() + // Create setup with model update logic + let setup = NodeEditorSetup::new({ + let nodes = nodes.clone(); + move |node_id, delta_x, delta_y| { + for i in 0..nodes.row_count() { + if let Some(mut node) = nodes.row_data(i) { + if node.id == node_id { + node.x += delta_x; + node.y += delta_y; + nodes.set_row_data(i, node); + break; + } + } + } } }); + // Wire all standard callbacks with one macro call + wire_node_editor!(window, setup); + // Animated link path generation (partial bezier based on progress) window.on_compute_animated_link_path({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); move |start_pin, end_pin, progress, _version| { let cache = ctrl.cache(); let cache = cache.borrow(); @@ -160,61 +153,6 @@ fn main() { } }); - // Geometry tracking - window.on_node_rect_changed({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.on_pin_position_changed({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); - - // Grid updates - window.on_request_grid_update({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move || { - if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); - } - } - }); - - window.on_update_viewport({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - } - }); - - // Node drag handling - window.on_node_drag_ended({ - let ctrl = ctrl.clone(); - move |delta_x, delta_y| { - let node_id = ctrl.dragged_node_id(); - for i in 0..nodes.row_count() { - if let Some(mut node) = nodes.row_data(i) { - if node.id == node_id { - node.x += delta_x; - node.y += delta_y; - nodes.set_row_data(i, node); - break; - } - } - } - } - }); - // Animation timer - updates animation time and link progress let animation_timer = Timer::default(); animation_timer.start( diff --git a/examples/animated-links/ui/animated-links.slint b/examples/animated-links/ui/animated-links.slint index 35d439e..b0ef60a 100644 --- a/examples/animated-links/ui/animated-links.slint +++ b/examples/animated-links/ui/animated-links.slint @@ -1,4 +1,14 @@ -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + LinkData, + NodeEditorComputations, + GeometryCallbacks, + ViewportState, + GeometryVersion, +} from "@slint-node-editor/node-editor.slint"; export struct NodeData { id: int, @@ -21,7 +31,7 @@ export struct AnimatedLinkData { } // Re-export for Rust -export { LinkData } +export { LinkData, NodeEditorComputations, GeometryCallbacks } // Custom animated link component with grow effect and sparkles component AnimatedLink inherits Rectangle { @@ -50,11 +60,8 @@ component AnimatedLink inherits Rectangle { height: 100%; // Apply dash pattern based on progress (creates grow effect) // Estimate path length as ~500px for bezier, adjust dash accordingly - stroke: selected ? link-color.brighter(50%) : - (hovered ? link-color.brighter(25%) : link-color); - stroke-width: selected ? line-width * 1.5 : - (hovered ? line-width * 1.25 : - (glow-intensity > 0.1 ? line-width * (1.0 + glow-intensity * 0.5) : line-width)); + stroke: selected ? link-color.brighter(50%) : (hovered ? link-color.brighter(25%) : link-color); + stroke-width: selected ? line-width * 1.5 : (hovered ? line-width * 1.25 : (glow-intensity > 0.1 ? line-width * (1.0 + glow-intensity * 0.5) : line-width)); fill: transparent; viewbox-x: 0; @@ -85,13 +92,13 @@ component AnimatedLink inherits Rectangle { component SimpleNode inherits BaseNode { in property title: "Node"; in property accent-color: #4a9eff; - width: 150px * self.zoom; - height: 100px * self.zoom; + width: 150px; + height: 100px; Rectangle { background: root.selected ? accent-color.darker(60%) : #333; - border-radius: 6px * root.zoom; - border-width: root.selected ? 2px * root.zoom : 1px * root.zoom; + border-radius: 6px; + border-width: root.selected ? 2px : 1px; border-color: root.selected ? accent-color : #666; // Animated gradient overlay @@ -99,16 +106,16 @@ component SimpleNode inherits BaseNode { x: 0; y: 0; width: parent.width; - height: 30px * root.zoom; - border-radius: 6px * root.zoom; + height: 30px; + border-radius: 6px; background: @linear-gradient(180deg, accent-color.with-alpha(0.3) 0%, transparent 100%); clip: true; // Only round top corners Rectangle { - y: parent.height - 6px * root.zoom; + y: parent.height - 6px; width: parent.width; - height: 6px * root.zoom; + height: 6px; background: root.selected ? accent-color.darker(60%) : #333; } } @@ -116,7 +123,7 @@ component SimpleNode inherits BaseNode { Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; font-weight: 600; } } @@ -128,17 +135,19 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; base-color: accent-color.darker(30%); hover-color: accent-color; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); + drag-started(id, x, y) => { + root.pin-drag-started(id, x, y); + } + drag-moved(id, x, y) => { + root.pin-drag-moved(id, x, y); + } + drag-ended(id, x, y) => { + root.pin-drag-ended(id, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } } // Output pin (right side) @@ -148,20 +157,20 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2 + 1; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; base-color: accent-color.darker(30%); hover-color: accent-color; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); + drag-started(id, x, y) => { + root.pin-drag-started(id, x, y); + } + drag-moved(id, x, y) => { + root.pin-drag-moved(id, x, y); + } + drag-ended(id, x, y) => { + root.pin-drag-ended(id, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } } - - callback report-position(int, int, int, length, length); } export component MainWindow inherits Window { @@ -174,7 +183,6 @@ export component MainWindow inherits Window { in property <[AnimatedLinkData]> animated-links; in property <[LinkData]> links <=> editor.links; in-out property grid_commands <=> editor.grid-commands; - in-out property dragged-node-id: 0; // Animation time (updated from Rust) in property animation-time: 0.0; @@ -182,10 +190,10 @@ export component MainWindow inherits Window { // Size for grid generation out property width_: self.width / 1px; out property height_: self.height / 1px; + out property zoom: editor.zoom; // Callbacks to Rust callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; callback request-grid-update <=> editor.request-grid-update; callback link-requested <=> editor.link-requested; @@ -193,12 +201,10 @@ export component MainWindow inherits Window { editor.refresh-links(); } - callback update-viewport(float, float, float); + // Note: viewport-changed and compute-link-path are now in NodeEditorComputations global callback node-drag-started <=> editor.node-drag-started; callback node-drag-ended <=> editor.node-drag-ended; - pure callback compute-link-path <=> editor.compute-link-path; callback compute-pin-at <=> editor.compute-pin-at; - callback compute-link-preview-path <=> editor.compute-link-preview-path; callback add-animated-link(/* from-pin */ int, /* to-pin */ int); // Animated link path with progress (0.0-1.0) pure callback compute-animated-link-path(/* start-pin */ int, /* end-pin */ int, /* progress */ float, /* version */ int) -> string; @@ -209,10 +215,6 @@ export component MainWindow inherits Window { background-color: #1a1a2e; grid-color: #2a2a4a; - viewport-changed => { - root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); - } - link-requested(start-pin, end-pin) => { root.add-animated-link(start-pin, end-pin); } @@ -220,7 +222,7 @@ export component MainWindow inherits Window { // Render animated links with effects for link in root.animated-links: AnimatedLink { // Use animated path that grows based on progress - path-commands: root.compute-animated-link-path(link.start-pin-id, link.end-pin-id, link.progress, editor.geometry-version); + path-commands: root.compute-animated-link-path(link.start-pin-id, link.end-pin-id, link.progress, GeometryVersion.version); link-color: link.color; line-width: link.line-width > 0 ? link.line-width * 1px : 2px; progress: link.progress; @@ -231,24 +233,12 @@ export component MainWindow inherits Window { for data in root.nodes: SimpleNode { node-id: data.id; title: data.title; - accent-color: data.id == 1 ? #ff6b6b : - data.id == 2 ? #4ecdc4 : - data.id == 3 ? #ffe66d : - data.id == 4 ? #a855f7 : #4a9eff; + accent-color: data.id == 1 ? #ff6b6b : data.id == 2 ? #4ecdc4 : data.id == 3 ? #ffe66d : data.id == 4 ? #a855f7 : #4a9eff; world-x: data.x * 1px; world-y: data.y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - drag-offset-x: data.id == root.dragged-node-id ? editor.drag-offset-x : 0px; - drag-offset-y: data.id == root.dragged-node-id ? editor.drag-offset-y : 0px; - - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - report-position(pid, nid, pt, x, y) => { - editor.report-pin-position(pid, nid, pt, x, y); - } + viewport-width: root.width; + viewport-height: root.height; + pin-drag-started(pin-id, x, y) => { editor.start-link-from-pin(pin-id, x, y); } @@ -259,17 +249,6 @@ export component MainWindow inherits Window { editor.update-link-end(x, y); editor.complete-link-creation(); } - drag-started(id, already-selected, wx, wy) => { - root.dragged-node-id = id; - editor.start-node-drag(id, already-selected, wx, wy); - } - drag-moved(id, dx, dy) => { - editor.update-node-drag(dx, dy); - } - drag-ended(id, dx, dy) => { - root.dragged-node-id = 0; - editor.end-node-drag(dx, dy); - } } } @@ -316,10 +295,30 @@ export component MainWindow inherits Window { padding: 8px; spacing: 3px; - Text { text: "Features"; color: #88aaff; font-size: 10px; font-weight: 600; } - Text { text: "- Links snake/grow from source to target"; color: #cccccc; font-size: 9px; } - Text { text: "- Glow effect on new connections"; color: #cccccc; font-size: 9px; } - Text { text: "- Color cycling for each new link"; color: #cccccc; font-size: 9px; } + Text { + text: "Features"; + color: #88aaff; + font-size: 10px; + font-weight: 600; + } + + Text { + text: "- Links snake/grow from source to target"; + color: #cccccc; + font-size: 9px; + } + + Text { + text: "- Glow effect on new connections"; + color: #cccccc; + font-size: 9px; + } + + Text { + text: "- Color cycling for each new link"; + color: #cccccc; + font-size: 9px; + } } } @@ -333,11 +332,36 @@ export component MainWindow inherits Window { padding: 8px; spacing: 3px; - Text { text: "Controls"; color: #88aaff; font-size: 10px; font-weight: 600; } - Text { text: "Create link: Drag output pin to input pin"; color: #cccccc; font-size: 9px; } - Text { text: "Pan view: Middle mouse button drag"; color: #cccccc; font-size: 9px; } - Text { text: "Zoom: Mouse scroll wheel"; color: #cccccc; font-size: 9px; } - Text { text: "Move node: Left mouse drag on node"; color: #cccccc; font-size: 9px; } + Text { + text: "Controls"; + color: #88aaff; + font-size: 10px; + font-weight: 600; + } + + Text { + text: "Create link: Drag output pin to input pin"; + color: #cccccc; + font-size: 9px; + } + + Text { + text: "Pan view: Middle mouse button drag"; + color: #cccccc; + font-size: 9px; + } + + Text { + text: "Zoom: Mouse scroll wheel"; + color: #cccccc; + font-size: 9px; + } + + Text { + text: "Move node: Left mouse drag on node"; + color: #cccccc; + font-size: 9px; + } } } } diff --git a/examples/custom-shapes/src/main.rs b/examples/custom-shapes/src/main.rs index 046c5f4..1b86170 100644 --- a/examples/custom-shapes/src/main.rs +++ b/examples/custom-shapes/src/main.rs @@ -1,37 +1,30 @@ use slint::{Color, Model, ModelRc, SharedString, VecModel}; -use slint_node_editor::{NodeEditorController, NodeGeometry}; +use slint_node_editor::{NodeEditorSetup, NodeGeometry, wire_node_editor}; use std::rc::Rc; slint::include_modules!(); /// Generate an orthogonal (Manhattan) path: Horizontal -> Vertical -> Horizontal +/// Uses pure world coordinates - zoom is handled by the container's transform-scale fn generate_manhattan_path( start_x: f32, start_y: f32, end_x: f32, end_y: f32, - zoom: f32, ) -> String { - // Scale coordinates by zoom - let sx = start_x * zoom; - let sy = start_y * zoom; - let ex = end_x * zoom; - let ey = end_y * zoom; - // Calculate midpoint for the vertical segment - let mid_x = (sx + ex) / 2.0; + let mid_x = (start_x + end_x) / 2.0; // Construct SVG path command // M sx sy -> Move to start // L mid_x sy -> Line to first corner // L mid_x ey -> Line to second corner // L ex ey -> Line to end - format!("M {} {} L {} {} L {} {} L {} {}", sx, sy, mid_x, sy, mid_x, ey, ex, ey) + format!("M {} {} L {} {} L {} {} L {} {}", start_x, start_y, mid_x, start_y, mid_x, end_y, end_x, end_y) } fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); let w = window.as_weak(); // Set up nodes @@ -60,20 +53,35 @@ fn main() { }, ])))); - // Custom link path computation callback - window.on_compute_link_path({ - let ctrl = ctrl.clone(); + // Create setup with model update logic + let setup = NodeEditorSetup::new({ + let nodes = nodes.clone(); + move |node_id, delta_x, delta_y| { + for i in 0..nodes.row_count() { + if let Some(mut node) = nodes.row_data(i) { + if node.id == node_id { + node.x += delta_x; + node.y += delta_y; + nodes.set_row_data(i, node); + break; + } + } + } + } + }); + + // Wire all standard callbacks with one macro call + wire_node_editor!(window, setup); + + // Custom link path computation via global callback + window.global::().on_compute_link_path({ + let ctrl = setup.controller().clone(); let w = w.clone(); move |start_pin, end_pin, _version, _zoom: f32, _pan_x: f32, _pan_y: f32| { let w = match w.upgrade() { Some(w) => w, None => return SharedString::default() }; let style = w.get_link_style(); - let zoom = w.get_zoom(); let bezier_offset = w.get_bezier_min_offset(); - // Use the controller's cache to get pin positions - // Note: We need to use the lower-level cache API because we're doing custom logic - // that isn't wrapped by the simple `compute_link_path` helper in the controller - // when we want orthogonal routing. let cache = ctrl.cache(); let cache = cache.borrow(); @@ -81,7 +89,6 @@ fn main() { let end_pos = cache.pin_positions.get(&end_pin); if let (Some(start), Some(end)) = (start_pos, end_pos) { - // Resolve absolute positions if let (Some(start_rect), Some(end_rect)) = ( cache.node_rects.get(&start.node_id).map(|n| n.rect()), cache.node_rects.get(&end.node_id).map(|n| n.rect()), @@ -92,10 +99,10 @@ fn main() { let ey = end_rect.1 + end.rel_y; if style == "orthogonal" { - generate_manhattan_path(sx, sy, ex, ey, zoom).into() + generate_manhattan_path(sx, sy, ex, ey).into() } else { - // Fallback to standard Bezier (using library helper) - slint_node_editor::generate_bezier_path(sx, sy, ex, ey, zoom, bezier_offset).into() + // Use zoom=1.0 since transform-scale handles zoom + slint_node_editor::generate_bezier_path(sx, sy, ex, ey, 1.0, bezier_offset).into() } } else { SharedString::default() @@ -106,62 +113,5 @@ fn main() { } }); - // Standard callbacks via controller - window.on_node_drag_started(ctrl.node_drag_started_callback()); - - window.on_node_rect_changed({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.on_pin_position_changed({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); - - window.on_request_grid_update({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move || { - if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); - } - } - }); - - window.on_update_viewport({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - } - }); - - // Node drag - window.on_node_drag_ended({ - let ctrl = ctrl.clone(); - move |delta_x, delta_y| { - let node_id = ctrl.dragged_node_id(); - for i in 0..nodes.row_count() { - if let Some(mut node) = nodes.row_data(i) { - if node.id == node_id { - node.x += delta_x; - node.y += delta_y; - nodes.set_row_data(i, node); - break; - } - } - } - } - }); - - window.invoke_request_grid_update(); window.run().unwrap(); } diff --git a/examples/custom-shapes/ui/main.slint b/examples/custom-shapes/ui/main.slint index 9ecb62a..499c062 100644 --- a/examples/custom-shapes/ui/main.slint +++ b/examples/custom-shapes/ui/main.slint @@ -1,4 +1,12 @@ -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + LinkData, + NodeEditorComputations, + GeometryCallbacks, +} from "@slint-node-editor/node-editor.slint"; import { ComboBox } from "std-widgets.slint"; export struct NodeData { @@ -7,73 +15,49 @@ export struct NodeData { x: float, y: float, } - -export { LinkData } +export { LinkData, NodeEditorComputations, GeometryCallbacks } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 150px * self.zoom; - height: 100px * self.zoom; + node-width: 150px; + node-height: 100px; Rectangle { + width: 100%; + height: 100%; background: root.selected ? #4a6a9a : #333; - border-radius: 4px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 4px; + border-width: 1px; border-color: #666; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; } } // Input pin Pin { x: 0px; - y: parent.height / 2 - self.height / 2; + y: root.node-height / 2 - self.height / 2; pin-id: root.node-id * 2; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } // Output pin Pin { - x: 0px; - y: parent.height / 2 - self.height / 2; - pin-id: root.node-id * 2; - node-id: root.node-id; - pin-type: PinTypes.input; - zoom: root.zoom; - node-screen-x: root.screen-x; - node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } - } - - // Output pin - Pin { - x: parent.width - self.width; - y: parent.height / 2 - self.height / 2; + x: root.node-width - self.width; + y: root.node-height / 2 - self.height / 2; pin-id: root.node-id * 2 + 1; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } - - callback report-position(int, int, int, length, length); } export component MainWindow inherits Window { @@ -82,9 +66,8 @@ export component MainWindow inherits Window { height: 600px; in property <[NodeData]> nodes; - in property <[LinkData]> links; + in property <[LinkData]> links <=> editor.links; in-out property grid_commands <=> editor.grid-commands; - in-out property dragged-node-id: 0; // Property to control link style out property link-style: "bezier"; // "bezier" or "orthogonal" @@ -97,56 +80,27 @@ export component MainWindow inherits Window { in-out property geometry-version <=> editor.geometry-version; // Callbacks to Rust - callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; callback request-grid-update <=> editor.request-grid-update; + // Note: geometry tracking and viewport changes are now handled via globals // Function to refresh links after geometry is reported public function refresh-links() { editor.refresh-links(); } - callback update-viewport(/* zoom */ float, /* pan-x */ float, /* pan-y */ float); callback node-drag-started <=> editor.node-drag-started; callback node-drag-ended <=> editor.node-drag-ended; - pure callback compute-link-path <=> editor.compute-link-path; editor := NodeEditor { width: 100%; height: 100%; - links <=> root.links; - - viewport-changed => { - root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); - } for data in root.nodes: SimpleNode { node-id: data.id; title: data.title; world-x: data.x * 1px; world-y: data.y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - drag-offset-x: data.id == root.dragged-node-id ? editor.drag-offset-x : 0px; - drag-offset-y: data.id == root.dragged-node-id ? editor.drag-offset-y : 0px; - - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - report-position(pid, nid, pt, x, y) => { - editor.report-pin-position(pid, nid, pt, x, y); - } - drag-started(id, already-selected, wx, wy) => { - root.dragged-node-id = id; - editor.start-node-drag(id, already-selected, wx, wy); - } - drag-moved(id, dx, dy) => { - editor.update-node-drag(dx, dy); - } - drag-ended(id, dx, dy) => { - root.dragged-node-id = 0; - editor.end-node-drag(dx, dy); - } + viewport-width: editor.width; + viewport-height: editor.height; } } @@ -164,7 +118,6 @@ export component MainWindow inherits Window { HorizontalLayout { padding: 10px; spacing: 10px; - Text { text: "Link Style:"; color: white; diff --git a/examples/custom-shapes/ui/main.slint.new b/examples/custom-shapes/ui/main.slint.new new file mode 100644 index 0000000..539a91f --- /dev/null +++ b/examples/custom-shapes/ui/main.slint.new @@ -0,0 +1,134 @@ +import { NodeEditor, BaseNode, Pin, PinTypes, LinkData, NodeEditorComputations, GeometryCallbacks } from "@slint-node-editor/node-editor.slint"; +import { ComboBox } from "std-widgets.slint"; + +export struct NodeData { + id: int, + title: string, + x: float, + y: float, +} + +export { LinkData, NodeEditorComputations, GeometryCallbacks } + +component SimpleNode inherits BaseNode { + in property title: "Node"; + node-width: 150px; + node-height: 100px; + + Rectangle { + width: 100%; + height: 100%; + background: root.selected ? #4a6a9a : #333; + border-radius: 4px; + border-width: 1px; + border-color: #666; + + Text { + text: root.title; + color: white; + font-size: 14px; + } + } + + // Input pin + Pin { + x: 0px; + y: root.node-height / 2 - self.height / 2; + pin-id: root.node-id * 2; + node-id: root.node-id; + pin-type: PinTypes.input; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + } + + // Output pin + Pin { + x: root.node-width - self.width; + y: root.node-height / 2 - self.height / 2; + pin-id: root.node-id * 2 + 1; + node-id: root.node-id; + pin-type: PinTypes.output; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + } +} + +export component MainWindow inherits Window { + title: "Custom Link Shapes Example"; + width: 800px; + height: 600px; + + in property <[NodeData]> nodes; + in property <[LinkData]> links <=> editor.links; + in-out property grid_commands <=> editor.grid-commands; + + // Property to control link style + out property link-style: "bezier"; // "bezier" or "orthogonal" + + // Configuration for Rust + out property width_: self.width / 1px; + out property height_: self.height / 1px; + out property zoom: editor.zoom; + out property bezier-min-offset: editor.bezier-min-offset; + in-out property geometry-version <=> editor.geometry-version; + + // Callbacks to Rust + callback node-rect-changed <=> editor.node-rect-changed; + callback request-grid-update <=> editor.request-grid-update; + + // Function to refresh links after geometry is reported + public function refresh-links() { + editor.refresh-links(); + } + // Note: viewport-changed and compute-link-path are now in NodeEditorComputations global + callback node-drag-started <=> editor.node-drag-started; + callback node-drag-ended <=> editor.node-drag-ended; + + editor := NodeEditor { + width: 100%; + height: 100%; + + for data in root.nodes: SimpleNode { + node-id: data.id; + title: data.title; + world-x: data.x * 1px; + world-y: data.y * 1px; + viewport-width: editor.width; + viewport-height: editor.height; + } + } + + // Control panel + Rectangle { + x: 20px; + y: 20px; + width: 200px; + height: 50px; + background: #2d2d2dee; + border-radius: 8px; + border-color: #555; + border-width: 1px; + + HorizontalLayout { + padding: 10px; + spacing: 10px; + + Text { + text: "Link Style:"; + color: white; + vertical-alignment: center; + } + + ComboBox { + width: 100px; + model: ["bezier", "orthogonal"]; + current-value: "bezier"; + selected(val) => { + root.link-style = val; + // Increment geometry version to force re-render of links + editor.geometry-version += 1; + } + } + } + } +} diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index 5689dac..2219b59 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -1,15 +1,13 @@ use slint::{Color, Model, ModelRc, SharedString, VecModel}; -use slint_node_editor::NodeEditorController; +use slint_node_editor::{NodeEditorSetup, wire_node_editor}; use std::rc::Rc; slint::include_modules!(); fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); - let w = window.as_weak(); - // Set up nodes (keep reference for drag updates) + // Set up nodes let nodes = Rc::new(VecModel::from(vec![ NodeData { id: 1, title: SharedString::from("Node A"), x: 100.0, y: 100.0 }, NodeData { id: 2, title: SharedString::from("Node B"), x: 400.0, y: 200.0 }, @@ -26,53 +24,11 @@ fn main() { line_width: 2.0, }, ])))); - - // Core callbacks - controller handles the logic - window.on_compute_link_path(ctrl.compute_link_path_callback()); - window.on_node_drag_started(ctrl.node_drag_started_callback()); - - // Geometry tracking - update cache - window.on_node_rect_changed({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.on_pin_position_changed({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); - - // Grid updates - window.on_request_grid_update({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move || { - if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); - } - } - }); - - window.on_update_viewport({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - } - }); - - // Node drag - update positions in model - window.on_node_drag_ended({ - let ctrl = ctrl.clone(); - move |delta_x, delta_y| { - let node_id = ctrl.dragged_node_id(); + + // Create setup with model update logic + let setup = NodeEditorSetup::new({ + let nodes = nodes.clone(); + move |node_id, delta_x, delta_y| { for i in 0..nodes.row_count() { if let Some(mut node) = nodes.row_data(i) { if node.id == node_id { @@ -86,6 +42,8 @@ fn main() { } }); - window.invoke_request_grid_update(); + // Wire all callbacks with one macro call + wire_node_editor!(window, setup); + window.run().unwrap(); } diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index 82070c4..09e966d 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -1,4 +1,13 @@ -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + DragState, + GeometryCallbacks, + NodeEditorComputations, + LinkData, +} from "@slint-node-editor/node-editor.slint"; export struct NodeData { id: int, @@ -7,59 +16,51 @@ export struct NodeData { y: float, } -// Re-export LinkData for Rust -export { LinkData } +// Re-export for Rust +export { LinkData, DragState, GeometryCallbacks, NodeEditorComputations } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 150px * self.zoom; - height: 100px * self.zoom; + // Set node size for BaseNode + node-width: 250px; + node-height: 80px; + // Visual content - Add any bespoke UI here! Rectangle { + width: 100%; + height: 100%; background: root.selected ? #4a6a9a : #333; - border-radius: 4px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 4px; + border-width: 1px; border-color: #666; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; } } - // Single input pin + // Pins - no callback forwarding needed! Pin { x: 0px; - y: parent.height / 2 - self.height / 2; + y: root.node-height / 2 - self.height / 2; pin-id: root.node-id * 2; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } - // Single output pin Pin { - x: parent.width - self.width; - y: parent.height / 2 - self.height / 2; + x: root.node-width - self.width; + y: root.node-height / 2 - self.height / 2; pin-id: root.node-id * 2 + 1; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } - - // Helper to bubble up report-position - callback report-position(int, int, int, length, length); } export component MainWindow inherits Window { @@ -70,62 +71,25 @@ export component MainWindow inherits Window { in property <[NodeData]> nodes; in property <[LinkData]> links <=> editor.links; in-out property grid_commands <=> editor.grid-commands; - in-out property dragged-node-id: 0; // Size for grid generation (used by Rust) out property width_: self.width / 1px; out property height_: self.height / 1px; - // Callbacks to Rust - callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; - callback request-grid-update <=> editor.request-grid-update; - - // Function to refresh links after geometry is reported - public function refresh-links() { - editor.refresh-links(); - } - callback update-viewport(/* zoom */ float, /* pan-x */ float, /* pan-y */ float); - callback node-drag-started <=> editor.node-drag-started; - callback node-drag-ended <=> editor.node-drag-ended; - pure callback compute-link-path <=> editor.compute-link-path; - editor := NodeEditor { width: 100%; height: 100%; + grid-snapping: true; // Disable grid snapping for testing - viewport-changed => { - root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); - } - + // Zero boilerplate! Drag offsets computed automatically in BaseNode for data in root.nodes: SimpleNode { node-id: data.id; title: data.title; world-x: data.x * 1px; world-y: data.y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - drag-offset-x: data.id == root.dragged-node-id ? editor.drag-offset-x : 0px; - drag-offset-y: data.id == root.dragged-node-id ? editor.drag-offset-y : 0px; - - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - report-position(pid, nid, pt, x, y) => { - editor.report-pin-position(pid, nid, pt, x, y); - } - drag-started(id, already-selected, wx, wy) => { - root.dragged-node-id = id; - editor.start-node-drag(id, already-selected, wx, wy); - } - drag-moved(id, dx, dy) => { - editor.update-node-drag(dx, dy); - } - drag-ended(id, dx, dy) => { - root.dragged-node-id = 0; - editor.end-node-drag(dx, dy); - } + viewport-width: editor.width; + viewport-height: editor.height; + selected: NodeEditorComputations.is-node-selected(data.id, editor.selection-version); } } } diff --git a/examples/pin-compatibility/src/main.rs b/examples/pin-compatibility/src/main.rs index a9e5e58..18c131a 100644 --- a/examples/pin-compatibility/src/main.rs +++ b/examples/pin-compatibility/src/main.rs @@ -16,7 +16,7 @@ use slint::{Color, Model, ModelRc, SharedString, VecModel}; use slint_node_editor::{ BasicLinkValidator, CompositeValidator, GeometryCache, LinkModel, LinkValidator, - NodeEditorController, SimpleNodeGeometry, ValidationError, ValidationResult, + NodeEditorSetup, SimpleNodeGeometry, ValidationError, ValidationResult, wire_node_editor, }; use std::rc::Rc; @@ -163,8 +163,6 @@ impl LinkModel for LinkData { fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); - let w = window.as_weak(); // Set up nodes let nodes = Rc::new(VecModel::from(vec![ @@ -190,35 +188,29 @@ fn main() { // Link ID counter let next_link_id = Rc::new(std::cell::Cell::new(1)); - // Core callbacks - window.on_compute_link_path({ - let ctrl = ctrl.clone(); - let w = window.as_weak(); - move |start_pin, end_pin, _version, _zoom: f32, _pan_x: f32, _pan_y: f32| { - let w = match w.upgrade() { - Some(w) => w, - None => return SharedString::default(), - }; - ctrl.cache() - .borrow() - .compute_link_path(start_pin, end_pin, w.get_zoom(), 50.0) - .unwrap_or_default() - .into() + // Create setup with model update logic + let setup = NodeEditorSetup::new({ + let nodes = nodes.clone(); + move |node_id, delta_x, delta_y| { + for i in 0..nodes.row_count() { + if let Some(mut node) = nodes.row_data(i) { + if node.id == node_id { + node.x += delta_x; + node.y += delta_y; + nodes.set_row_data(i, node); + break; + } + } + } } }); - window.on_node_drag_started(ctrl.node_drag_started_callback()); - // Pin hit detection for link completion - window.on_compute_pin_at({ - let ctrl = ctrl.clone(); - move |x, y| { - ctrl.cache().borrow().find_pin_at(x as f32, y as f32, 20.0) - } - }); + // Wire all standard callbacks with one macro call + wire_node_editor!(window, setup); // Link validation callback for hover feedback window.on_validate_link({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let links = links.clone(); move |start_pin, end_pin| { let cache = ctrl.cache(); @@ -238,44 +230,9 @@ fn main() { } }); - // Link preview path generation - window.on_compute_link_preview_path({ - let w = window.as_weak(); - move |start_x, start_y, end_x, end_y| { - let w = match w.upgrade() { - Some(w) => w, - None => return SharedString::default(), - }; - slint_node_editor::generate_bezier_path( - start_x as f32, - start_y as f32, - end_x as f32, - end_y as f32, - w.get_zoom(), - 50.0, - ) - .into() - } - }); - - // Geometry tracking - window.on_node_rect_changed({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.on_pin_position_changed({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); - // Link requested - validate and create window.on_link_requested({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let links = links.clone(); let next_link_id = next_link_id.clone(); move |start_pin, end_pin| { @@ -342,46 +299,6 @@ fn main() { } }); - // Grid updates - window.on_request_grid_update({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move || { - if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); - } - } - }); - - window.on_update_viewport({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - } - }); - - // Node drag - window.on_node_drag_ended({ - let ctrl = ctrl.clone(); - move |delta_x, delta_y| { - let node_id = ctrl.dragged_node_id(); - for i in 0..nodes.row_count() { - if let Some(mut node) = nodes.row_data(i) { - if node.id == node_id { - node.x += delta_x; - node.y += delta_y; - nodes.set_row_data(i, node); - break; - } - } - } - } - }); - // Print compatibility matrix on startup println!("\n=== Pin Type Compatibility Matrix ==="); println!("Try connecting pins to see validation in action!\n"); @@ -396,6 +313,5 @@ fn main() { println!(" Any -> All types"); println!("\nDrag from an output pin (right side) to an input pin (left side).\n"); - window.invoke_request_grid_update(); window.run().unwrap(); } diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index aed910c..cd87a89 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -1,4 +1,13 @@ -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + LinkData, + NodeEditorComputations, + GeometryCallbacks, + ViewportState, +} from "@slint-node-editor/node-editor.slint"; export struct NodeData { id: int, @@ -6,8 +15,7 @@ export struct NodeData { x: float, y: float, } - -export { LinkData } +export { LinkData, NodeEditorComputations, GeometryCallbacks } export global DataTypes { out property execute: 0; @@ -44,20 +52,14 @@ component ValidatedPin inherits Rectangle { in property pin-id; in property node-id; in property pin-type; - in property zoom: 1.0; in property base-color: #888888; in property node-screen-x: 0px; in property node-screen-y: 0px; in property refresh-trigger: 0; in property valid-target-pin-id: 0; // Pin ID that's a valid drop target - callback report-position(int, int, int, length, length); - callback drag-started(int, length, length); - callback drag-moved(int, length, length); - callback drag-ended(int, length, length); - property is-valid-target: root.valid-target-pin-id == root.pin-id && root.pin-id > 0; - property pin-size: 12px * zoom; + property pin-size: 12px; width: pin-size; height: pin-size; @@ -66,38 +68,32 @@ component ValidatedPin inherits Rectangle { pin-id: root.pin-id; node-id: root.node-id; pin-type: root.pin-type; - zoom: root.zoom; base-color: root.base-color; - // Adjust screen position to account for ValidatedPin's position within node - node-screen-x: root.node-screen-x + root.x; - node-screen-y: root.node-screen-y + root.y; + node-screen-x: root.node-screen-x; // Just pass through - library handles offset via parent-offset + node-screen-y: root.node-screen-y; + parent-offset-x: root.x; + parent-offset-y: root.y; refresh-trigger: root.refresh-trigger; - - // Add ValidatedPin's offset to get position relative to node - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x + root.x, y + root.y); } - drag-started(id, x, y) => { root.drag-started(id, x, y); } - drag-moved(id, x, y) => { root.drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.drag-ended(id, x, y); } } // Green checkmark overlay for valid targets Rectangle { visible: root.is-valid-target; x: root.pin-size; - y: -4px * root.zoom; - width: 16px * root.zoom; - height: 16px * root.zoom; + y: -4px; + width: 16px; + height: 16px; background: #22c55e; border-radius: self.width / 2; // Checkmark using Path Path { - x: 3px * root.zoom; - y: 4px * root.zoom; - width: 10px * root.zoom; - height: 8px * root.zoom; + x: 3px; + y: 4px; + width: 10px; + height: 8px; stroke: white; - stroke-width: 2px * root.zoom; + stroke-width: 2px; fill: transparent; commands: "M 0 4 L 3 7 L 9 1"; } @@ -109,125 +105,138 @@ component SourceNode inherits BaseNode { in property title: "Source"; in property pin-refresh-trigger: 0; in property valid-target-pin-id: 0; - width: L.node-width * self.zoom; - height: L.node-height * self.zoom; - - callback report-position(int, int, int, length, length); + width: L.node-width; + height: L.node-height; Rectangle { background: root.selected ? #4a6a9a : #2d2d2d; - border-radius: 6px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 6px; + border-width: 1px; border-color: #555; // Title Text { - x: 8px * root.zoom; - y: 6px * root.zoom; + x: 8px; + y: 6px; text: root.title; color: white; - font-size: 12px * root.zoom; + font-size: 12px; font-weight: 600; } // Pin labels for label[idx] in ["Execute", "Integer", "Float", "String", "Boolean", "Object", "Array", "Any"]: Text { - x: 8px * root.zoom; - y: (L.first-pin-y + idx * L.pin-spacing) * root.zoom; + x: 8px; + y: (L.first-pin-y + idx * L.pin-spacing); text: label; color: #aaa; - font-size: 10px * root.zoom; + font-size: 10px; } } // Output pins - direct children of node ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 0 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 100; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.execute; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 0 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 100; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.execute; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 1 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 101; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.integer; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 1 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 101; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.integer; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 2 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 102; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.float_; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 2 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 102; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.float_; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 3 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 103; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.string; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 3 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 103; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.string; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 4 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 104; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.boolean; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 4 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 104; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.boolean; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 5 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 105; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.object; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 5 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 105; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.object; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 6 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 106; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.array; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 6 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 106; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.array; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 7 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 107; node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.any; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 7 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 107; + node-id: root.node-id; + pin-type: PinTypes.output; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.any; + valid-target-pin-id: root.valid-target-pin-id; } } @@ -236,125 +245,138 @@ component SinkNode inherits BaseNode { in property title: "Sink"; in property pin-refresh-trigger: 0; in property valid-target-pin-id: 0; - width: L.node-width * self.zoom; - height: L.node-height * self.zoom; - - callback report-position(int, int, int, length, length); + width: L.node-width; + height: L.node-height; Rectangle { background: root.selected ? #4a6a9a : #2d2d2d; - border-radius: 6px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 6px; + border-width: 1px; border-color: #555; // Title Text { - x: 8px * root.zoom; - y: 6px * root.zoom; + x: 8px; + y: 6px; text: root.title; color: white; - font-size: 12px * root.zoom; + font-size: 12px; font-weight: 600; } // Pin labels for label[idx] in ["Execute", "Integer", "Float", "String", "Boolean", "Object", "Array", "Any"]: Text { - x: 20px * root.zoom; - y: (L.first-pin-y + idx * L.pin-spacing) * root.zoom; + x: 20px; + y: (L.first-pin-y + idx * L.pin-spacing); text: label; color: #aaa; - font-size: 10px * root.zoom; + font-size: 10px; } } // Input pins - direct children of node ValidatedPin { x: 0px; - y: (L.first-pin-y + 0 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 200; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.execute; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 0 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 200; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.execute; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: 0px; - y: (L.first-pin-y + 1 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 201; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.integer; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 1 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 201; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.integer; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: 0px; - y: (L.first-pin-y + 2 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 202; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.float_; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 2 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 202; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.float_; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: 0px; - y: (L.first-pin-y + 3 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 203; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.string; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 3 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 203; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.string; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: 0px; - y: (L.first-pin-y + 4 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 204; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.boolean; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 4 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 204; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.boolean; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: 0px; - y: (L.first-pin-y + 5 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 205; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.object; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 5 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 205; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.object; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: 0px; - y: (L.first-pin-y + 6 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 206; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.array; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 6 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 206; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.array; + valid-target-pin-id: root.valid-target-pin-id; } + ValidatedPin { x: 0px; - y: (L.first-pin-y + 7 * L.pin-spacing + 6px) * root.zoom - self.height/2; - pin-id: 207; node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; - node-screen-x: root.screen-x; node-screen-y: root.screen-y; - base-color: PinColors.any; valid-target-pin-id: root.valid-target-pin-id; - report-position(id, nid, t, x, y) => { root.report-position(id, nid, t, x, y); } - drag-started(id, x, y) => { root.pin-drag-started(id, x, y); } - drag-moved(id, x, y) => { root.pin-drag-moved(id, x, y); } - drag-ended(id, x, y) => { root.pin-drag-ended(id, x, y); } + y: (L.first-pin-y + 7 * L.pin-spacing + 6px) - self.height / 2; + pin-id: 207; + node-id: root.node-id; + pin-type: PinTypes.input; + refresh-trigger: root.pin-refresh-trigger; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; + base-color: PinColors.any; + valid-target-pin-id: root.valid-target-pin-id; } } @@ -374,17 +396,16 @@ export component MainWindow inherits Window { out property zoom: editor.zoom; callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; callback request-grid-update <=> editor.request-grid-update; callback link-requested <=> editor.link-requested; callback compute-pin-at <=> editor.compute-pin-at; - callback compute-link-preview-path <=> editor.compute-link-preview-path; - public function refresh-links() { editor.refresh-links(); } - callback update-viewport(float, float, float); + public function refresh-links() { + editor.refresh-links(); + } + // Note: viewport-changed and compute-link-path are now in NodeEditorComputations global callback node-drag-started <=> editor.node-drag-started; callback node-drag-ended <=> editor.node-drag-ended; - pure callback compute-link-path <=> editor.compute-link-path; // Link validation callback - returns true if connection is valid pure callback validate-link(/* start-pin */ int, /* end-pin */ int) -> bool; @@ -425,41 +446,16 @@ export component MainWindow inherits Window { width: 100%; height: 100%; - viewport-changed => { - root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); - } - + // Node instantiations - BaseNode handles zoom/pan/drag via globals SourceNode { node-id: 1; title: "Source"; world-x: nodes.length > 0 ? nodes[0].x * 1px : 100px; world-y: nodes.length > 0 ? nodes[0].y * 1px : 100px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - drag-offset-x: 1 == root.dragged-node-id ? editor.drag-offset-x : 0px; - drag-offset-y: 1 == root.dragged-node-id ? editor.drag-offset-y : 0px; pin-refresh-trigger: root.pin-refresh-counter; valid-target-pin-id: root.valid-target-pin-id; - - report-rect(id, x, y, w, h) => { editor.report-node-rect(id, x, y, w, h); } - report-position(pid, nid, pt, x, y) => { editor.report-pin-position(pid, nid, pt, x, y); } - drag-started(id, sel, wx, wy) => { root.dragged-node-id = id; editor.start-node-drag(id, sel, wx, wy); } - drag-moved(id, dx, dy) => { editor.update-node-drag(dx, dy); } - drag-ended(id, dx, dy) => { root.dragged-node-id = 0; editor.end-node-drag(dx, dy); } - pin-drag-started(id, x, y) => { - root.valid-target-pin-id = 0; - editor.start-link-from-pin(id, x, y); - } - pin-drag-moved(id, x, y) => { - editor.update-link-end(x, y); - root.update-valid-target(editor.link-start-pin-id, x, y); - } - pin-drag-ended(id, x, y) => { - root.valid-target-pin-id = 0; - editor.update-link-end(x, y); - editor.complete-link-creation(); - } + viewport-width: editor.width; + viewport-height: editor.height; } SinkNode { @@ -467,48 +463,43 @@ export component MainWindow inherits Window { title: "Sink"; world-x: nodes.length > 1 ? nodes[1].x * 1px : 350px; world-y: nodes.length > 1 ? nodes[1].y * 1px : 100px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - drag-offset-x: 2 == root.dragged-node-id ? editor.drag-offset-x : 0px; - drag-offset-y: 2 == root.dragged-node-id ? editor.drag-offset-y : 0px; pin-refresh-trigger: root.pin-refresh-counter; valid-target-pin-id: root.valid-target-pin-id; - - report-rect(id, x, y, w, h) => { editor.report-node-rect(id, x, y, w, h); } - report-position(pid, nid, pt, x, y) => { editor.report-pin-position(pid, nid, pt, x, y); } - drag-started(id, sel, wx, wy) => { root.dragged-node-id = id; editor.start-node-drag(id, sel, wx, wy); } - drag-moved(id, dx, dy) => { editor.update-node-drag(dx, dy); } - drag-ended(id, dx, dy) => { root.dragged-node-id = 0; editor.end-node-drag(dx, dy); } - pin-drag-started(id, x, y) => { - root.valid-target-pin-id = 0; - editor.start-link-from-pin(id, x, y); - } - pin-drag-moved(id, x, y) => { - editor.update-link-end(x, y); - root.update-valid-target(editor.link-start-pin-id, x, y); - } - pin-drag-ended(id, x, y) => { - root.valid-target-pin-id = 0; - editor.update-link-end(x, y); - editor.complete-link-creation(); - } + viewport-width: editor.width; + viewport-height: editor.height; } } // Instructions Rectangle { - x: 10px; y: parent.height - 80px; - width: 350px; height: 70px; + x: 10px; + y: parent.height - 80px; + width: 350px; + height: 70px; background: #000000aa; border-radius: 8px; VerticalLayout { padding: 8px; spacing: 2px; - Text { text: "Type Compatibility:"; color: #fff; font-size: 11px; font-weight: 600; } - Text { text: "Same types connect. Int→Float, Bool→Int work."; color: #aaa; font-size: 9px; } - Text { text: "Numeric→String works. Any connects to all."; color: #aaa; font-size: 9px; } + Text { + text: "Type Compatibility:"; + color: #fff; + font-size: 11px; + font-weight: 600; + } + + Text { + text: "Same types connect. Int→Float, Bool→Int work."; + color: #aaa; + font-size: 9px; + } + + Text { + text: "Numeric→String works. Any connects to all."; + color: #aaa; + font-size: 9px; + } } } } diff --git a/examples/sugiyama/src/main.rs b/examples/sugiyama/src/main.rs index 8b9d9fc..a224bb3 100644 --- a/examples/sugiyama/src/main.rs +++ b/examples/sugiyama/src/main.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::rc::Rc; use slint::{Color, Model, ModelRc, SharedString, VecModel}; -use slint_node_editor::{sugiyama_layout, Direction, NodeEditorController, SugiyamaConfig}; +use slint_node_editor::{sugiyama_layout, wire_node_editor, Direction, NodeEditorSetup, SugiyamaConfig}; slint::include_modules!(); @@ -27,40 +27,43 @@ fn build_node_index(nodes: &VecModel) -> HashMap { fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); let w = window.as_weak(); - // Create a DAG with 8 nodes: - // - // 1 ──► 2 ──► 4 ──► 7 - // │ │ │ - // ▼ ▼ ▼ - // 3 ──► 5 ──► 6 ──► 8 - // - let nodes = Rc::new(VecModel::from(vec![ - NodeData { id: 1, title: SharedString::from("Input"), x: 50.0, y: 50.0 }, - NodeData { id: 2, title: SharedString::from("Parse"), x: 50.0, y: 120.0 }, - NodeData { id: 3, title: SharedString::from("Validate"), x: 50.0, y: 190.0 }, - NodeData { id: 4, title: SharedString::from("Transform"), x: 50.0, y: 260.0 }, - NodeData { id: 5, title: SharedString::from("Filter"), x: 50.0, y: 330.0 }, - NodeData { id: 6, title: SharedString::from("Merge"), x: 50.0, y: 400.0 }, - NodeData { id: 7, title: SharedString::from("Format"), x: 50.0, y: 470.0 }, - NodeData { id: 8, title: SharedString::from("Output"), x: 50.0, y: 540.0 }, - ])); + // Create a 50x50 grid of nodes (2500 nodes total) + const GRID_SIZE: i32 = 50; + let mut node_vec = Vec::with_capacity((GRID_SIZE * GRID_SIZE) as usize); + for row in 0..GRID_SIZE { + for col in 0..GRID_SIZE { + let id = row * GRID_SIZE + col + 1; // 1-based IDs + node_vec.push(NodeData { + id, + title: SharedString::from(format!("{},{}", row, col)), + x: (col * 140) as f32 + 50.0, + y: (row * 80) as f32 + 50.0, + }); + } + } + let nodes = Rc::new(VecModel::from(node_vec)); window.set_nodes(ModelRc::from(nodes.clone())); - // DAG edges as (source_node_id, target_node_id) — single source of truth - // Pin encoding: input = id*2, output = id*2+1 - let dag_edges: Vec<(i32, i32)> = vec![ - (1, 2), (1, 3), - (2, 4), (2, 5), - (3, 5), - (4, 6), (4, 7), - (5, 6), - (6, 8), (7, 8), - ]; - - // Derive LinkData from dag_edges so they can't drift out of sync + // Create edges: each node connects to right neighbor and bottom neighbor + // This creates a DAG flowing right and down + let mut dag_edges: Vec<(i32, i32)> = Vec::new(); + for row in 0..GRID_SIZE { + for col in 0..GRID_SIZE { + let id = row * GRID_SIZE + col + 1; + // Connect to right neighbor + if col < GRID_SIZE - 1 { + dag_edges.push((id, id + 1)); + } + // Connect to bottom neighbor + if row < GRID_SIZE - 1 { + dag_edges.push((id, id + GRID_SIZE)); + } + } + } + + // Derive LinkData from dag_edges let link_color = Color::from_argb_u8(255, 100, 180, 255); let link_data: Vec = dag_edges .iter() @@ -79,6 +82,7 @@ fn main() { window.on_layout_requested({ let nodes = nodes.clone(); let dag_edges = dag_edges.clone(); + let w = w.clone(); move || { let node_sizes: Vec<(i32, (f64, f64))> = (0..nodes.row_count()) .filter_map(|i| nodes.row_data(i)) @@ -103,12 +107,19 @@ fn main() { } } } + + // Increment version to trigger link recalculation + if let Some(w) = w.upgrade() { + let geom_ver = w.global::(); + geom_ver.set_version(geom_ver.get_version() + 1); + } } }); // Scramble button callback window.on_scramble_requested({ let nodes = nodes.clone(); + let w = w.clone(); move || { for i in 0..nodes.row_count() { if let Some(mut node) = nodes.row_data(i) { @@ -117,53 +128,19 @@ fn main() { nodes.set_row_data(i, node); } } - } - }); - - // Core callbacks - window.on_compute_link_path(ctrl.compute_link_path_callback()); - window.on_node_drag_started(ctrl.node_drag_started_callback()); - - window.on_node_rect_changed({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.on_pin_position_changed({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); - - window.on_request_grid_update({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move || { - if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); - } - } - }); - - window.on_update_viewport({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { + + // Increment version to trigger link recalculation if let Some(w) = w.upgrade() { - ctrl.set_viewport(z, pan_x, pan_y); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); + let geom_ver = w.global::(); + geom_ver.set_version(geom_ver.get_version() + 1); } } }); - window.on_node_drag_ended({ - let ctrl = ctrl.clone(); + // Create setup with model update logic + let setup = NodeEditorSetup::new({ let nodes = nodes.clone(); - move |delta_x, delta_y| { - let node_id = ctrl.dragged_node_id(); + move |node_id, delta_x, delta_y| { for i in 0..nodes.row_count() { if let Some(mut node) = nodes.row_data(i) { if node.id == node_id { @@ -177,6 +154,8 @@ fn main() { } }); - window.invoke_request_grid_update(); + // Wire all callbacks with one macro call + wire_node_editor!(window, setup); + window.run().unwrap(); } diff --git a/examples/sugiyama/ui/sugiyama.slint b/examples/sugiyama/ui/sugiyama.slint index dc960d4..7d73ec2 100644 --- a/examples/sugiyama/ui/sugiyama.slint +++ b/examples/sugiyama/ui/sugiyama.slint @@ -1,4 +1,13 @@ -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + LinkData, + GeometryCallbacks, + NodeEditorComputations, + GeometryVersion, +} from "@slint-node-editor/node-editor.slint"; import { Button } from "std-widgets.slint"; export struct NodeData { @@ -7,24 +16,25 @@ export struct NodeData { x: float, y: float, } - -export { LinkData } +export { LinkData, GeometryCallbacks, NodeEditorComputations, GeometryVersion } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 120px * self.zoom; - height: 60px * self.zoom; + node-width: 120px; + node-height: 60px; Rectangle { + width: 100%; + height: 100%; background: root.selected ? #4a6a9a : #333; - border-radius: 4px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 4px; + border-width: 1px; border-color: #666; Text { text: root.title; color: white; - font-size: 12px * root.zoom; + font-size: 12px; } } @@ -35,12 +45,8 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } // Output pin @@ -50,15 +56,9 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2 + 1; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } - - callback report-position(int, int, int, length, length); } export component MainWindow inherits Window { @@ -69,18 +69,10 @@ export component MainWindow inherits Window { in property <[NodeData]> nodes; in property <[LinkData]> links <=> editor.links; in-out property grid_commands <=> editor.grid-commands; - in-out property dragged-node-id: 0; out property width_: self.width / 1px; out property height_: self.height / 1px; - callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; - callback request-grid-update <=> editor.request-grid-update; - callback update-viewport(float, float, float); - callback node-drag-started <=> editor.node-drag-started; - callback node-drag-ended <=> editor.node-drag-ended; - pure callback compute-link-path <=> editor.compute-link-path; callback layout-requested(); callback scramble-requested(); @@ -96,6 +88,7 @@ export component MainWindow inherits Window { root.layout-requested(); } } + Button { text: "Scramble"; clicked => { @@ -105,38 +98,14 @@ export component MainWindow inherits Window { } editor := NodeEditor { - viewport-changed => { - root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); - } + // Zero boilerplate! All callbacks handled by globals for data in root.nodes: SimpleNode { node-id: data.id; title: data.title; world-x: data.x * 1px; world-y: data.y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - drag-offset-x: data.id == root.dragged-node-id ? editor.drag-offset-x : 0px; - drag-offset-y: data.id == root.dragged-node-id ? editor.drag-offset-y : 0px; - - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - report-position(pid, nid, pt, x, y) => { - editor.report-pin-position(pid, nid, pt, x, y); - } - drag-started(id, already-selected, wx, wy) => { - root.dragged-node-id = id; - editor.start-node-drag(id, already-selected, wx, wy); - } - drag-moved(id, dx, dy) => { - editor.update-node-drag(dx, dy); - } - drag-ended(id, dx, dy) => { - root.dragged-node-id = 0; - editor.end-node-drag(dx, dy); - } + selected: NodeEditorComputations.is-node-selected(data.id, editor.selection-version); } } } diff --git a/examples/zoom-stress-test/src/main.rs b/examples/zoom-stress-test/src/main.rs index cb10af6..3352103 100644 --- a/examples/zoom-stress-test/src/main.rs +++ b/examples/zoom-stress-test/src/main.rs @@ -3,15 +3,13 @@ // Tests widget scaling behavior at various zoom levels with three complex nodes. use slint::{Color, ModelRc, SharedString, VecModel}; -use slint_node_editor::NodeEditorController; +use slint_node_editor::{NodeEditorSetup, wire_node_editor}; use std::rc::Rc; slint::include_modules!(); fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); - let w = window.as_weak(); // Create input nodes model let input_nodes: Rc> = Rc::new(VecModel::from(vec![InputNodeData { @@ -81,45 +79,13 @@ fn main() { window.set_display_nodes(ModelRc::from(display_nodes.clone())); window.set_links(ModelRc::from(links.clone())); - // Core callbacks - controller handles link path computation - window.on_compute_link_path(ctrl.compute_link_path_callback()); - - // Geometry tracking - window.on_node_rect_changed({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.on_pin_position_changed({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } + // Create setup with model update logic (no-op since nodes don't move in this example) + let setup = NodeEditorSetup::new(|_node_id, _delta_x, _delta_y| { + // Nodes are not draggable in this stress test }); - // Grid updates - window.on_request_grid_update({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move || { - if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); - } - } - }); - - window.on_update_viewport({ - let ctrl = ctrl.clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - } - }); + // Wire all standard callbacks with one macro call + wire_node_editor!(window, setup); // Input node callbacks window.on_input_text_changed(|id, val| { diff --git a/examples/zoom-stress-test/ui/control_node.slint b/examples/zoom-stress-test/ui/control_node.slint index 8d6efab..991499f 100644 --- a/examples/zoom-stress-test/ui/control_node.slint +++ b/examples/zoom-stress-test/ui/control_node.slint @@ -12,7 +12,7 @@ // - Minimal (zoom <= 0.25): Colored rectangle with title import { CheckBox, Switch, Button, Slider } from "std-widgets.slint"; -import { Pin, PinTypes, BaseNode } from "@slint-node-editor/node-editor.slint"; +import { Pin, PinTypes, BaseNode, ViewportState } from "@slint-node-editor/node-editor.slint"; // Control node data passed from Rust export struct ControlNodeData { @@ -53,8 +53,8 @@ export component ControlNode inherits BaseNode { in property min-node-height: 40px; // LOD level: 2=full, 1=simplified, 0=minimal - property lod-level: self.zoom > lod-full-threshold ? 2 - : self.zoom > lod-simplified-threshold ? 1 + property lod-level: ViewportState.zoom > lod-full-threshold ? 2 + : ViewportState.zoom > lod-simplified-threshold ? 1 : 0; // === Layout constants === @@ -66,7 +66,6 @@ export component ControlNode inherits BaseNode { // Pin dimensions property base-pin-size: 12px; - property pin-size: max(base-pin-size * self.zoom, 8px); // Minimum 8px for clickability // === Size calculations per LOD === // Full size @@ -90,9 +89,8 @@ export component ControlNode inherits BaseNode { : lod-level == 1 ? simplified-height : minimal-height; - // Apply zoom with minimum size floor - width: max(effective-base-width * self.zoom, minimal-width); - height: max(effective-base-height * self.zoom, minimal-height); + node-width: lod-level == 2 ? full-width : lod-level == 1 ? simplified-width : minimal-width; + node-height: lod-level == 2 ? full-height : lod-level == 1 ? simplified-height : minimal-height; // Node colors property node-color: #5d3d3d; @@ -103,24 +101,24 @@ export component ControlNode inherits BaseNode { width: 100%; height: 100%; background: root.selected ? root.node-color-selected : root.node-color; - border-radius: 4px * root.zoom; + border-radius: 4px; border-width: root.selected ? 2px : 1px; border-color: root.selected ? #f88 : #555; VerticalLayout { - padding: root.content-padding * root.zoom; - spacing: root.row-spacing * root.zoom; + padding: root.content-padding; + spacing: root.row-spacing; // Title bar Rectangle { - height: root.title-height * root.zoom; + height: root.title-height; background: root.selected ? root.node-color-selected.darker(0.2) : root.node-color.darker(0.2); - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } @@ -128,10 +126,10 @@ export component ControlNode inherits BaseNode { // Row 1: Checkboxes HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 16px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 16px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; alignment: start; CheckBox { @@ -153,16 +151,16 @@ export component ControlNode inherits BaseNode { // Row 2: Switch HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 12px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 12px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; alignment: start; Text { text: "Enable:"; color: #ccc; - font-size: 12px * root.zoom; + font-size: 12px; vertical-alignment: center; } @@ -176,24 +174,24 @@ export component ControlNode inherits BaseNode { Text { text: root.switch-value ? "ON" : "OFF"; color: root.switch-value ? #8f8 : #f88; - font-size: 11px * root.zoom; + font-size: 11px; vertical-alignment: center; } } // Row 3: Slider HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 8px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 8px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; Text { text: "Level:"; color: #ccc; - font-size: 12px * root.zoom; + font-size: 12px; vertical-alignment: center; - width: 40px * root.zoom; + width: 40px; } Slider { @@ -209,19 +207,19 @@ export component ControlNode inherits BaseNode { Text { text: round(root.slider-value * 100) + "%"; color: #aaa; - font-size: 10px * root.zoom; + font-size: 10px; vertical-alignment: center; - width: 35px * root.zoom; + width: 35px; horizontal-alignment: right; } } // Row 4: Buttons HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 8px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 8px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; alignment: center; Button { @@ -253,22 +251,22 @@ export component ControlNode inherits BaseNode { width: 100%; height: 100%; background: root.selected ? root.node-color-selected : root.node-color; - border-radius: 4px * root.zoom; + border-radius: 4px; border-width: root.selected ? 2px : 1px; border-color: root.selected ? #f88 : #555; VerticalLayout { - padding: root.content-padding * root.zoom; + padding: root.content-padding; Rectangle { - height: root.title-height * root.zoom; + height: root.title-height; background: root.selected ? root.node-color-selected.darker(0.2) : root.node-color.darker(0.2); - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: max(14px * root.zoom, 9px); + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } @@ -296,12 +294,11 @@ export component ControlNode inherits BaseNode { // === Input pin (left side) === input-pin := Pin { - x: root.content-padding * root.zoom; - y: parent.height / 2 - root.pin-size / 2; + x: root.content-padding; + y: parent.height / 2 - root.base-pin-size / 2; pin-id: root.input-pin-id; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #2196F3; @@ -318,12 +315,11 @@ export component ControlNode inherits BaseNode { // === Output pin (right side) === output-pin := Pin { - x: parent.width - root.content-padding * root.zoom - root.pin-size; - y: parent.height / 2 - root.pin-size / 2; + x: parent.width - root.content-padding - root.base-pin-size; + y: parent.height / 2 - root.base-pin-size / 2; pin-id: root.output-pin-id; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #FF9800; diff --git a/examples/zoom-stress-test/ui/display_node.slint b/examples/zoom-stress-test/ui/display_node.slint index e296c62..05209d3 100644 --- a/examples/zoom-stress-test/ui/display_node.slint +++ b/examples/zoom-stress-test/ui/display_node.slint @@ -12,7 +12,7 @@ // - Minimal (zoom <= 0.25): Colored rectangle with title import { ProgressIndicator, Spinner } from "std-widgets.slint"; -import { Pin, PinTypes, BaseNode } from "@slint-node-editor/node-editor.slint"; +import { Pin, PinTypes, BaseNode, ViewportState } from "@slint-node-editor/node-editor.slint"; // Display node data passed from Rust export struct DisplayNodeData { @@ -49,8 +49,8 @@ export component DisplayNode inherits BaseNode { in property min-node-height: 40px; // LOD level: 2=full, 1=simplified, 0=minimal - property lod-level: self.zoom > lod-full-threshold ? 2 - : self.zoom > lod-simplified-threshold ? 1 + property lod-level: ViewportState.zoom > lod-full-threshold ? 2 + : ViewportState.zoom > lod-simplified-threshold ? 1 : 0; // === Layout constants === @@ -62,7 +62,6 @@ export component DisplayNode inherits BaseNode { // Pin dimensions property base-pin-size: 12px; - property pin-size: max(base-pin-size * self.zoom, 8px); // Minimum 8px for clickability // === Size calculations per LOD === // Full size @@ -86,9 +85,8 @@ export component DisplayNode inherits BaseNode { : lod-level == 1 ? simplified-height : minimal-height; - // Apply zoom with minimum size floor - width: max(effective-base-width * self.zoom, minimal-width); - height: max(effective-base-height * self.zoom, minimal-height); + node-width: lod-level == 2 ? full-width : lod-level == 1 ? simplified-width : minimal-width; + node-height: lod-level == 2 ? full-height : lod-level == 1 ? simplified-height : minimal-height; // Node colors property node-color: #3d3d5d; @@ -99,24 +97,24 @@ export component DisplayNode inherits BaseNode { width: 100%; height: 100%; background: root.selected ? root.node-color-selected : root.node-color; - border-radius: 4px * root.zoom; + border-radius: 4px; border-width: root.selected ? 2px : 1px; border-color: root.selected ? #88f : #555; VerticalLayout { - padding: root.content-padding * root.zoom; - spacing: root.row-spacing * root.zoom; + padding: root.content-padding; + spacing: root.row-spacing; // Title bar Rectangle { - height: root.title-height * root.zoom; + height: root.title-height; background: root.selected ? root.node-color-selected.darker(0.2) : root.node-color.darker(0.2); - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } @@ -124,15 +122,15 @@ export component DisplayNode inherits BaseNode { // Row 1: Large status text HorizontalLayout { - height: root.row-height * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; alignment: center; Text { text: root.status-text; color: #fff; - font-size: 16px * root.zoom; + font-size: 16px; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; @@ -140,31 +138,31 @@ export component DisplayNode inherits BaseNode { if root.is-loading: Spinner { indeterminate: true; - width: 20px * root.zoom; - height: 20px * root.zoom; + width: 20px; + height: 20px; } } // Row 2: Progress bar HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 8px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 8px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; Text { text: round(root.progress * 100) + "%"; color: #aaa; - font-size: 11px * root.zoom; + font-size: 11px; vertical-alignment: center; - width: 35px * root.zoom; + width: 35px; } Rectangle { horizontal-stretch: 1; - height: 12px * root.zoom; + height: 12px; background: #333; - border-radius: 6px * root.zoom; + border-radius: 6px; Rectangle { x: 0; @@ -172,114 +170,114 @@ export component DisplayNode inherits BaseNode { width: parent.width * root.progress; height: parent.height; background: #4CAF50; - border-radius: 6px * root.zoom; + border-radius: 6px; } } } // Row 3: Color preview with RGB values HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 8px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 8px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; Text { text: "Color:"; color: #ccc; - font-size: 11px * root.zoom; + font-size: 11px; vertical-alignment: center; } Rectangle { - width: 40px * root.zoom; - height: 20px * root.zoom; + width: 40px; + height: 20px; background: rgb(root.color-r * 255, root.color-g * 255, root.color-b * 255); - border-radius: 4px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 4px; + border-width: 1px; border-color: #666; } Text { text: "R:" + round(root.color-r * 255); color: #f88; - font-size: 9px * root.zoom; + font-size: 9px; vertical-alignment: center; } Text { text: "G:" + round(root.color-g * 255); color: #8f8; - font-size: 9px * root.zoom; + font-size: 9px; vertical-alignment: center; } Text { text: "B:" + round(root.color-b * 255); color: #88f; - font-size: 9px * root.zoom; + font-size: 9px; vertical-alignment: center; } } // Row 4: Multiple font sizes demonstration HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 6px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 6px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; alignment: start; Text { text: "XL"; color: #ccc; - font-size: 18px * root.zoom; + font-size: 18px; vertical-alignment: center; } Text { text: "Large"; color: #aaa; - font-size: 14px * root.zoom; + font-size: 14px; vertical-alignment: center; } Text { text: "Med"; color: #888; - font-size: 11px * root.zoom; + font-size: 11px; vertical-alignment: center; } Text { text: "Small"; color: #666; - font-size: 9px * root.zoom; + font-size: 9px; vertical-alignment: center; } Text { text: "Tiny"; color: #555; - font-size: 7px * root.zoom; + font-size: 7px; vertical-alignment: center; } } // Row 5: Mini color swatches HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 4px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 4px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; alignment: start; for color in [#e74c3c, #e67e22, #f1c40f, #2ecc71, #3498db, #9b59b6, #1abc9c, #34495e]: Rectangle { - width: 18px * root.zoom; - height: 18px * root.zoom; + width: 18px; + height: 18px; background: color; - border-radius: 3px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 3px; + border-width: 1px; border-color: #00000040; } } @@ -291,24 +289,24 @@ export component DisplayNode inherits BaseNode { width: 100%; height: 100%; background: root.selected ? root.node-color-selected : root.node-color; - border-radius: 4px * root.zoom; + border-radius: 4px; border-width: root.selected ? 2px : 1px; border-color: root.selected ? #88f : #555; VerticalLayout { - padding: root.content-padding * root.zoom; - spacing: root.row-spacing * root.zoom; + padding: root.content-padding; + spacing: root.row-spacing; // Title bar Rectangle { - height: root.title-height * root.zoom; + height: root.title-height; background: root.selected ? root.node-color-selected.darker(0.2) : root.node-color.darker(0.2); - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: max(14px * root.zoom, 9px); + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } @@ -316,9 +314,9 @@ export component DisplayNode inherits BaseNode { // Simplified progress bar Rectangle { - height: 8px * root.zoom; + height: 8px; background: #333; - border-radius: 4px * root.zoom; + border-radius: 4px; Rectangle { x: 0; @@ -326,7 +324,7 @@ export component DisplayNode inherits BaseNode { width: parent.width * root.progress; height: parent.height; background: #4CAF50; - border-radius: 4px * root.zoom; + border-radius: 4px; } } } @@ -352,12 +350,11 @@ export component DisplayNode inherits BaseNode { // === Input pin (left side, centered) === input-pin := Pin { - x: root.content-padding * root.zoom; - y: parent.height / 2 - root.pin-size / 2; + x: root.content-padding; + y: parent.height / 2 - root.base-pin-size / 2; pin-id: root.input-pin-id; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #9C27B0; diff --git a/examples/zoom-stress-test/ui/input_node.slint b/examples/zoom-stress-test/ui/input_node.slint index 451f393..fd261cd 100644 --- a/examples/zoom-stress-test/ui/input_node.slint +++ b/examples/zoom-stress-test/ui/input_node.slint @@ -11,7 +11,7 @@ // - Minimal (zoom <= 0.25): Colored rectangle with title import { LineEdit, SpinBox, ComboBox } from "std-widgets.slint"; -import { Pin, PinTypes, BaseNode } from "@slint-node-editor/node-editor.slint"; +import { Pin, PinTypes, BaseNode, ViewportState } from "@slint-node-editor/node-editor.slint"; // Input node data passed from Rust export struct InputNodeData { @@ -47,8 +47,8 @@ export component InputNode inherits BaseNode { in property min-node-height: 40px; // LOD level: 2=full, 1=simplified, 0=minimal - property lod-level: self.zoom > lod-full-threshold ? 2 - : self.zoom > lod-simplified-threshold ? 1 + property lod-level: ViewportState.zoom > lod-full-threshold ? 2 + : ViewportState.zoom > lod-simplified-threshold ? 1 : 0; // === Layout constants === @@ -59,11 +59,10 @@ export component InputNode inherits BaseNode { property label-width: 50px; property pin-area-width: 20px; - // Pin dimensions + // Pin dimensions (world-space) property base-pin-size: 12px; - property pin-size: max(base-pin-size * self.zoom, 8px); // Minimum 8px for clickability - // === Size calculations per LOD === + // === Size calculations per LOD (world-space) === // Full size property full-width: 280px; property full-height: content-padding + title-height + content-padding @@ -77,17 +76,13 @@ export component InputNode inherits BaseNode { property minimal-width: root.min-node-width; property minimal-height: root.min-node-height; - // Effective dimensions based on LOD - property effective-base-width: lod-level == 2 ? full-width - : lod-level == 1 ? simplified-width - : minimal-width; - property effective-base-height: lod-level == 2 ? full-height - : lod-level == 1 ? simplified-height - : minimal-height; - - // Apply zoom with minimum size floor - width: max(effective-base-width * self.zoom, minimal-width); - height: max(effective-base-height * self.zoom, minimal-height); + // Effective dimensions based on LOD (no zoom - container handles it) + node-width: lod-level == 2 ? full-width + : lod-level == 1 ? simplified-width + : minimal-width; + node-height: lod-level == 2 ? full-height + : lod-level == 1 ? simplified-height + : minimal-height; // Node colors property node-color: #3d5d3d; @@ -98,24 +93,24 @@ export component InputNode inherits BaseNode { width: 100%; height: 100%; background: root.selected ? root.node-color-selected : root.node-color; - border-radius: 4px * root.zoom; + border-radius: 4px; border-width: root.selected ? 2px : 1px; border-color: root.selected ? #8f8 : #555; VerticalLayout { - padding: root.content-padding * root.zoom; - spacing: root.row-spacing * root.zoom; + padding: root.content-padding; + spacing: root.row-spacing; // Title bar Rectangle { - height: root.title-height * root.zoom; + height: root.title-height; background: root.selected ? root.node-color-selected.darker(0.2) : root.node-color.darker(0.2); - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } @@ -123,16 +118,16 @@ export component InputNode inherits BaseNode { // Row 1: Text input HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 8px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 8px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; Text { - width: root.label-width * root.zoom; + width: root.label-width; text: "Text:"; color: #ccc; - font-size: 12px * root.zoom; + font-size: 12px; vertical-alignment: center; } @@ -148,16 +143,16 @@ export component InputNode inherits BaseNode { // Row 2: Numeric input HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 8px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 8px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; Text { - width: root.label-width * root.zoom; + width: root.label-width; text: "Value:"; color: #ccc; - font-size: 12px * root.zoom; + font-size: 12px; vertical-alignment: center; } @@ -174,16 +169,16 @@ export component InputNode inherits BaseNode { // Row 3: Dropdown HorizontalLayout { - height: root.row-height * root.zoom; - spacing: 8px * root.zoom; - padding-left: root.pin-area-width * root.zoom; - padding-right: root.pin-area-width * root.zoom; + height: root.row-height; + spacing: 8px; + padding-left: root.pin-area-width; + padding-right: root.pin-area-width; Text { - width: root.label-width * root.zoom; + width: root.label-width; text: "Mode:"; color: #ccc; - font-size: 12px * root.zoom; + font-size: 12px; vertical-alignment: center; } @@ -204,22 +199,22 @@ export component InputNode inherits BaseNode { width: 100%; height: 100%; background: root.selected ? root.node-color-selected : root.node-color; - border-radius: 4px * root.zoom; + border-radius: 4px; border-width: root.selected ? 2px : 1px; border-color: root.selected ? #8f8 : #555; VerticalLayout { - padding: root.content-padding * root.zoom; + padding: root.content-padding; Rectangle { - height: root.title-height * root.zoom; + height: root.title-height; background: root.selected ? root.node-color-selected.darker(0.2) : root.node-color.darker(0.2); - border-radius: 4px * root.zoom; + border-radius: 4px; Text { text: root.title; color: white; - font-size: max(14px * root.zoom, 9px); // Minimum readable font + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } @@ -245,14 +240,13 @@ export component InputNode inherits BaseNode { } } - // === Output pin (visible at all LOD levels, but positioned differently) === + // === Output pin (visible at all LOD levels) === output-pin := Pin { - x: parent.width - root.content-padding * root.zoom - root.pin-size; - y: parent.height / 2 - root.pin-size / 2; + x: parent.width - root.content-padding - root.base-pin-size; + y: parent.height / 2 - root.base-pin-size / 2; pin-id: root.output-pin-id; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; base-color: #4CAF50; diff --git a/examples/zoom-stress-test/ui/ui.slint b/examples/zoom-stress-test/ui/ui.slint index 794176e..55852c4 100644 --- a/examples/zoom-stress-test/ui/ui.slint +++ b/examples/zoom-stress-test/ui/ui.slint @@ -7,14 +7,20 @@ // - Hit targets are adequate for interaction // - Layout consistency across zoom levels -import { NodeEditor, Link, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + Link, + LinkData, + NodeEditorComputations, + GeometryCallbacks, +} from "@slint-node-editor/node-editor.slint"; import { InputNode, InputNodeData } from "input_node.slint"; import { ControlNode, ControlNodeData } from "control_node.slint"; import { DisplayNode, DisplayNodeData } from "display_node.slint"; import { Button } from "std-widgets.slint"; // Re-export for Rust -export { InputNodeData, ControlNodeData, DisplayNodeData, LinkData } +export { InputNodeData, ControlNodeData, DisplayNodeData, LinkData, NodeEditorComputations, GeometryCallbacks } export component MainWindow inherits Window { title: "Zoom Stress Test - Widget Scaling"; @@ -44,10 +50,7 @@ export component MainWindow inherits Window { // Callbacks to Rust callback request-grid-update <=> editor.request-grid-update; - callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; - callback update-viewport(float, float, float); - pure callback compute-link-path <=> editor.compute-link-path; + // Note: geometry tracking and viewport changes are now handled via globals // Input node callbacks callback input-text-changed(int, string); @@ -71,8 +74,7 @@ export component MainWindow inherits Window { root.width / 2, root.height / 2, (root.width / 2 - root.pan-x) / root.zoom / 1px, - (root.height / 2 - root.pan-y) / root.zoom / 1px - ); + (root.height / 2 - root.pan-y) / root.zoom / 1px); } editor := NodeEditor { @@ -89,19 +91,12 @@ export component MainWindow inherits Window { grid-commands <=> root.grid-commands; links <=> root.links; - viewport-changed => { - root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); - } - // Input nodes for data in root.input-nodes: InputNode { node-id: data.id; title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; text-value: data.text-value; spin-value: data.spin-value; combo-index: data.combo-index; @@ -111,22 +106,6 @@ export component MainWindow inherits Window { min-node-width: editor.min-node-width; min-node-height: editor.min-node-height; - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - pin-position-changed(pin-id, node-id, pin-type, x, y) => { - editor.report-pin-position(pin-id, node-id, pin-type, x, y); - } - pin-drag-started(pin-id, x, y) => { - editor.start-link-from-pin(pin-id, x, y); - } - pin-drag-moved(pin-id, x, y) => { - editor.update-link-end(x, y); - } - pin-drag-ended(pin-id, x, y) => { - editor.update-link-end(x, y); - editor.complete-link-creation(); - } text-changed(id, val) => { root.input-text-changed(id, val); } @@ -144,9 +123,6 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; check-a: data.check-a; check-b: data.check-b; switch-value: data.switch-value; @@ -157,22 +133,6 @@ export component MainWindow inherits Window { min-node-width: editor.min-node-width; min-node-height: editor.min-node-height; - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - pin-position-changed(pin-id, node-id, pin-type, x, y) => { - editor.report-pin-position(pin-id, node-id, pin-type, x, y); - } - pin-drag-started(pin-id, x, y) => { - editor.start-link-from-pin(pin-id, x, y); - } - pin-drag-moved(pin-id, x, y) => { - editor.update-link-end(x, y); - } - pin-drag-ended(pin-id, x, y) => { - editor.update-link-end(x, y); - editor.complete-link-creation(); - } check-a-toggled(id, val) => { root.control-check-a-toggled(id, val); } @@ -196,9 +156,6 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; progress: data.progress; status-text: data.status-text; color-r: data.color-r; @@ -210,23 +167,6 @@ export component MainWindow inherits Window { lod-simplified-threshold: editor.lod-simplified-threshold; min-node-width: editor.min-node-width; min-node-height: editor.min-node-height; - - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - pin-position-changed(pin-id, node-id, pin-type, x, y) => { - editor.report-pin-position(pin-id, node-id, pin-type, x, y); - } - pin-drag-started(pin-id, x, y) => { - editor.start-link-from-pin(pin-id, x, y); - } - pin-drag-moved(pin-id, x, y) => { - editor.update-link-end(x, y); - } - pin-drag-ended(pin-id, x, y) => { - editor.update-link-end(x, y); - editor.complete-link-creation(); - } } } @@ -251,14 +191,8 @@ export component MainWindow inherits Window { } Text { - text: zoom < 0.5 ? "Very small" : - zoom < 0.8 ? "Small" : - zoom < 1.2 ? "Normal" : - zoom < 2.0 ? "Large" : "Very large"; - color: zoom < 0.5 ? #f88 : - zoom < 0.8 ? #fa8 : - zoom < 1.2 ? #8f8 : - zoom < 2.0 ? #8af : #f8f; + text: zoom < 0.5 ? "Very small" : zoom < 0.8 ? "Small" : zoom < 1.2 ? "Normal" : zoom < 2.0 ? "Large" : "Very large"; + color: zoom < 0.5 ? #f88 : zoom < 0.8 ? #fa8 : zoom < 1.2 ? #8f8 : zoom < 2.0 ? #8af : #f8f; font-size: 11px; horizontal-alignment: center; } diff --git a/node-editor-app.slint b/node-editor-app.slint new file mode 100644 index 0000000..75172bf --- /dev/null +++ b/node-editor-app.slint @@ -0,0 +1,42 @@ +// Node Editor Application Wrapper +// +// This component wraps NodeEditor and automatically handles all internal +// callback routing between BaseNode/Pin components and the NodeEditor. +// Examples using this component don't need any callback forwarding boilerplate. +// +// Usage: +// ```slint +// import { NodeEditorApp, BaseNode, Pin } from "node-editor-app.slint"; +// +// MainWindow { +// NodeEditorApp { +// // Your nodes - no callback forwarding needed! +// for node in nodes: MyCustomNode { ... } +// } +// } +// ``` + +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + ViewportState, + NodeStyleDefaults, + MinimapNode, + MinimapPosition, + LinkData, +} from "node-editor.slint"; + +export { BaseNode, Pin, PinTypes, ViewportState, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData } + +/// Application-level wrapper that auto-wires internal callbacks. +/// Use this instead of NodeEditor directly to eliminate callback boilerplate. +export component NodeEditorApp inherits NodeEditor { + // All NodeEditor properties are inherited and accessible + + // Internal wiring: BaseNode/Pin callbacks → NodeEditor functions + // This happens automatically without examples needing to forward + + @children +} diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 859e426..823616b 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -17,6 +17,148 @@ export global PinTypes { out property output: 2; } +/// Viewport state shared from NodeEditor to all child components. +/// This allows nodes and pins to access zoom/pan without requiring +/// these properties to be passed explicitly by examples. +export global ViewportState { + /// Current zoom level (updated by NodeEditor) + in-out property zoom: 1.0; + /// Current pan X offset (updated by NodeEditor) + in-out property pan-x: 0px; + /// Current pan Y offset (updated by NodeEditor) + in-out property pan-y: 0px; + /// True screen mouse coordinates (unaffected by transform-scale) + in-out property screen-mouse-x: 0px; + in-out property screen-mouse-y: 0px; +} + +/// Drag state shared from NodeEditor to all child nodes. +/// This allows BaseNode to compute drag offsets internally without +/// requiring examples to pass drag state to each node. +export global DragState { + /// Whether any node is currently being dragged + in-out property is-dragging: false; + /// ID of the node currently being dragged (0 if none) + in-out property dragged-node-id: 0; + /// X offset of the drag operation + in-out property drag-offset-x: 0px; + /// Y offset of the drag operation + in-out property drag-offset-y: 0px; +} + +/// Geometry version counter for link path invalidation. +/// BaseNode/Pin increment this automatically when geometry changes. +/// NodeEditor binds to it for live link updates. +export global GeometryVersion { + /// Counter incremented on every geometry change + in-out property version: 0; +} + +/// Link creation state - updated by Pin, consumed by NodeEditor. +/// This allows Pin to trigger link creation without manual callback forwarding. +export global LinkCreation { + /// Version counter - incremented on every start/update/complete + in-out property version: 0; + /// Current state: 0=idle, 1=started, 2=updating, 3=complete + in-out property state: 0; + /// The pin ID that started the link + in-out property start-pin-id: 0; + /// Current X position (screen coordinates) + in-out property current-x: 0px; + /// Current Y position (screen coordinates) + in-out property current-y: 0px; +} + +/// Selection request state - updated by BaseNode, consumed by NodeEditor. +/// This allows BaseNode to request selection changes without manual callback forwarding. +export global SelectionRequest { + /// Version counter - incremented on every selection request + in-out property version: 0; + /// The node ID to select/deselect + in-out property node-id: 0; + /// Whether shift key was held during the click + in-out property shift-held: false; +} + +/// Internal geometry callback registry. +/// NodeEditor's Rust code wires these up automatically - examples don't touch this! +/// BaseNode/Pin components call these to report geometry and interactions. +export global GeometryCallbacks { + /// Report node rectangle (world coordinates) + callback report-node-rect(/* id */ int, /* x */ length, /* y */ length, /* width */ length, /* height */ length); + /// Report pin position relative to node + callback report-pin-position(/* pin-id */ int, /* node-id */ int, /* pin-type */ int, /* rel-x */ length, /* rel-y */ length); + /// Start node drag + callback start-node-drag(/* node-id */ int, /* already-selected */ bool, /* world-x */ length, /* world-y */ length); + /// Update node drag + callback update-node-drag(/* delta-x */ length, /* delta-y */ length); + /// End node drag + callback end-node-drag(/* delta-x */ length, /* delta-y */ length); + /// Click on node + callback click-node(/* node-id */ int, /* shift-held */ bool); + /// Double-click on node + callback double-click-node(/* node-id */ int); + /// Start link from pin + callback start-link-from-pin(/* pin-id */ int, /* abs-x */ length, /* abs-y */ length); + /// Update link preview during pin drag + callback update-link-preview(/* x */ length, /* y */ length); + /// Complete link creation + callback complete-link(); +} + +/// Computational callbacks wired by Rust - examples don't touch these! +/// NodeEditor calls these for expensive operations. +export global NodeEditorComputations { + /// Compute SVG path commands for a link between two pins + pure callback compute-link-path( + /* start-pin-id */ int, + /* end-pin-id */ int, + /* geometry-version */ int, + /* zoom */ float, + /* pan-x */ float, + /* pan-y */ float) -> string; + + /// Viewport state changed (zoom or pan) + callback viewport-changed( + /* zoom */ float, + /* pan-x */ float, + /* pan-y */ float); + + /// Check if a node is selected + pure callback is-node-selected( + /* node-id */ int, + /* selection-version */ int) -> bool; + + /// Check if a link is selected + pure callback is-link-selected( + /* link-id */ int, + /* selection-version */ int) -> bool; + + /// Compute pin ID at screen coordinates (for hit testing) + pure callback compute-pin-at( + /* x */ float, + /* y */ float, + /* pin-hit-radius */ float) -> int; + + /// Compute link preview path (for link creation drag) + pure callback compute-link-preview-path( + /* start-x */ float, + /* start-y */ float, + /* end-x */ float, + /* end-y */ float, + /* zoom */ float, + /* bezier-offset */ float) -> string; + + /// Compute node IDs in selection box (for rubber-band selection) + pure callback compute-box-selection( + /* x */ float, + /* y */ float, + /* width */ float, + /* height */ float) -> [int]; + + /// Sync selection state to Rust (called when selection changes) + callback sync-selection-to-nodes(/* node-ids */ [int]); +} /// Optional layout helpers for common node structures. /// @@ -209,8 +351,7 @@ export component Minimap inherits Rectangle { root.path-version, root.scale, root.minimap-padding / 1px - (root.graph-min-x / 1px) * root.scale, - root.minimap-padding / 1px - (root.graph-min-y / 1px) * root.scale - ); + root.minimap-padding / 1px - (root.graph-min-y / 1px) * root.scale); // Use computed path, or nodes-path if provided externally property effective-path: root.nodes-path != "" ? root.nodes-path : root.computed-path; @@ -312,17 +453,27 @@ export component Link inherits Path { } /// Base component for all node types in the node editor. +/// Nodes are positioned in pure WORLD coordinates. The parent container (at pan-x, pan-y) +/// applies transform-scale for zoom, so nodes just use x: world-x, y: world-y directly. +/// +/// Zoom/pan values are accessed from the ViewportState global - examples don't need to pass them. export component BaseNode inherits Rectangle { // === Common Properties === in property node-id; in property selected: false; in property world-x; - in property world-y; - in property zoom: 1.0; - in property pan-x: 0px; - in property pan-y: 0px; - in property drag-offset-x: 0px; - in property drag-offset-y: 0px; + in property world-y; + // Persistent position offset (survives drag end until model updates) + property persistent-offset-x: 0px; + property persistent-offset-y: 0px; + // === Viewport (for visibility culling) - provided by parent === + in property viewport-width: 10000px; + in property viewport-height: 10000px; + + // === Node Size === + // Set these to match your node's visual dimensions. + in property node-width: 150px; + in property node-height: 100px; // === Styling Properties (overridable) === in property background-color: #2d2d2d; @@ -346,48 +497,75 @@ export component BaseNode inherits Rectangle { callback report-rect(int, length, length, length, length); // === Computed Properties === - out property screen-x: (world-x + drag-offset-x) * zoom + pan-x; - out property screen-y: (world-y + drag-offset-y) * zoom + pan-y; + // World position (for reporting to Rust) + // For multi-node drag: selected nodes that aren't being directly dragged use global DragState offset + property world-pos-x: world-x + persistent-offset-x + (DragState.is-dragging && root.selected && node-id != DragState.dragged-node-id ? DragState.drag-offset-x : drag-area.current-drag-offset-x); + property world-pos-y: world-y + persistent-offset-y + (DragState.is-dragging && root.selected && node-id != DragState.dragged-node-id ? DragState.drag-offset-y : drag-area.current-drag-offset-y); + // Screen position (for external reference only) + out property screen-x: world-pos-x * ViewportState.zoom + ViewportState.pan-x; + out property screen-y: world-pos-y * ViewportState.zoom + ViewportState.pan-y; // === Common Styling === - x: screen-x; - y: screen-y; - background: selected ? background-color-selected : background-color; - border-radius: border-radius-base * zoom; - border-width: selected ? border-width-selected * zoom : border-width-normal * zoom; - border-color: selected ? border-color-selected : border-color-normal; - - // Report rect on changes + // Pure world coordinates - container handles pan and transform-scale handles zoom + x: world-pos-x; + y: world-pos-y; + width: node-width; + height: node-height; + // BaseNode is transparent - visual styling should be in child elements + background: transparent; + clip: false; + + // Report rect on changes (in WORLD coordinates - without offset) init => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed node-id => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } - changed drag-offset-x => { + changed world-x => { + // If model updated to our expected position, clear persistent offset + if abs(persistent-offset-x) > 0.1px { + root.persistent-offset-x = 0px; + } if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } - changed drag-offset-y => { + changed world-y => { + // If model updated to our expected position, clear persistent offset + if abs(persistent-offset-y) > 0.1px { + root.persistent-offset-y = 0px; + } if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } - changed width => { + changed node-width => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } - changed height => { + changed node-height => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + } + } + + // Watch world-pos-x/y for live link updates during multi-node drag + changed world-pos-x => { + if (node-id > 0) { + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + } + } + changed world-pos-y => { + if (node-id > 0) { + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } @@ -395,11 +573,19 @@ export component BaseNode inherits Rectangle { property dbl-click-armed: false; dbl-click-timer := Timer { interval: 400ms; - triggered => { root.dbl-click-armed = false; } + triggered => { + root.dbl-click-armed = false; + } } // === Drag Handling === - TouchArea { + // TouchArea covers the full node area + drag-area := TouchArea { + x: 0; + y: 0; + width: root.node-width; + height: root.node-height; + property shift-held: false; property is-dragging: false; property drag-threshold: 5px; @@ -417,9 +603,13 @@ export component BaseNode inherits Rectangle { if event.kind == PointerEventKind.down && event.button == PointerEventButton.left { shift-held = event.modifiers.shift; is-dragging = false; - // Store ABSOLUTE mouse position to avoid coordinate system issues - start-abs-x = self.absolute-position.x + self.mouse-x; - start-abs-y = self.absolute-position.y + self.mouse-y; + + // Calculate and store true screen mouse position immediately + // absolute-position is in screen space, but self.mouse-x/y is in world space (inside scaled container) + ViewportState.screen-mouse-x = self.absolute-position.x + self.mouse-x * ViewportState.zoom; + ViewportState.screen-mouse-y = self.absolute-position.y + self.mouse-y * ViewportState.zoom; + start-abs-x = ViewportState.screen-mouse-x; + start-abs-y = ViewportState.screen-mouse-y; current-drag-offset-x = 0px; current-drag-offset-y = 0px; was-selected-on-press = selected; @@ -427,24 +617,38 @@ export component BaseNode inherits Rectangle { // Only select immediately if shift is held (multi-select toggle) // or if node is not already selected if shift-held || !selected { - root.clicked(node-id, shift-held); + SelectionRequest.node-id = node-id; + SelectionRequest.shift-held = shift-held; + SelectionRequest.version += 1; } // If node is already selected and shift not held, defer selection // until we know if it's a drag or a click } if event.kind == PointerEventKind.up && event.button == PointerEventButton.left { if is-dragging { + // Transfer drag offset to persistent before ending drag + root.persistent-offset-x += current-drag-offset-x; + root.persistent-offset-y += current-drag-offset-y; // End drag using the tracked offset, not recalculated from mouse position - root.drag-ended(node-id, current-drag-offset-x, current-drag-offset-y); + GeometryCallbacks.end-node-drag(current-drag-offset-x, current-drag-offset-y); is-dragging = false; + current-drag-offset-x = 0px; + current-drag-offset-y = 0px; + // Reset global drag state + DragState.is-dragging = false; + DragState.dragged-node-id = 0; + DragState.drag-offset-x = 0px; + DragState.drag-offset-y = 0px; } else { // Click without drag - if we deferred selection, do it now if was-selected-on-press && !shift-held { - root.clicked(node-id, shift-held); + SelectionRequest.node-id = node-id; + SelectionRequest.shift-held = shift-held; + SelectionRequest.version += 1; } // Double-click detection if root.dbl-click-armed { - root.double-clicked(node-id); + GeometryCallbacks.double-click-node(node-id); root.dbl-click-armed = false; } else { root.dbl-click-armed = true; @@ -456,17 +660,34 @@ export component BaseNode inherits Rectangle { moved => { if self.pressed { - // Calculate offset from start position using absolute coordinates + // Update screen mouse coordinates + // absolute-position is in screen space, but self.mouse-x/y is in world space (inside scaled container) + // So: screen_mouse = absolute_position + mouse_in_world * zoom + ViewportState.screen-mouse-x = self.absolute-position.x + self.mouse-x * ViewportState.zoom; + ViewportState.screen-mouse-y = self.absolute-position.y + self.mouse-y * ViewportState.zoom; + + // Calculate offset from start position using true screen coordinates if !is-dragging { - if abs((self.absolute-position.x + self.mouse-x) - start-abs-x) > drag-threshold || abs((self.absolute-position.y + self.mouse-y) - start-abs-y) > drag-threshold { + if abs(ViewportState.screen-mouse-x - start-abs-x) > drag-threshold || abs(ViewportState.screen-mouse-y - start-abs-y) > drag-threshold { is-dragging = true; - root.drag-started(node-id, selected, world-x, world-y); + GeometryCallbacks.start-node-drag(node-id, selected, world-x, world-y); + // Set global drag state for multi-node drag + DragState.is-dragging = true; + DragState.dragged-node-id = node-id; } } if is-dragging { - current-drag-offset-x = ((self.absolute-position.x + self.mouse-x) - start-abs-x) / zoom; - current-drag-offset-y = ((self.absolute-position.y + self.mouse-y) - start-abs-y) / zoom; - root.drag-moved(node-id, current-drag-offset-x, current-drag-offset-y); + // Calculate drag offset using true screen coordinates + current-drag-offset-x = (ViewportState.screen-mouse-x - start-abs-x) / ViewportState.zoom; + current-drag-offset-y = (ViewportState.screen-mouse-y - start-abs-y) / ViewportState.zoom; + + GeometryCallbacks.update-node-drag(current-drag-offset-x, current-drag-offset-y); + // Update global drag offset for other selected nodes + DragState.drag-offset-x = current-drag-offset-x; + DragState.drag-offset-y = current-drag-offset-y; + // Update geometry for live link updates + GeometryVersion.version += 1; + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } } @@ -478,22 +699,24 @@ export component Pin inherits Rectangle { in property pin-id; in property node-id; // Node this pin belongs to (for position reporting) in property pin-type; // Type of pin (input/output/custom) - in property zoom: 1.0; in property base-color: #888888; in property hover-color: #aaaaaa; in property base-size: 12px; - in property node-screen-x: 0px; + in property node-screen-x: 0px; // Needed for drag preview coordinates in property node-screen-y: 0px; + in property parent-offset-x: 0px; // For wrapped pins (offset from node) + in property parent-offset-y: 0px; // For wrapped pins (offset from node) /// Increment this to force pin position re-reporting (workaround for init timing) in property refresh-trigger: 0; - property pin-size: base-size * zoom; + // Pin size is constant in world space - zoom is handled by parent transform-scale + property pin-size: base-size; property pin-radius: pin-size / 2; - /// Pin center X relative to node top-left, UNSCALED by zoom - out property center-x: (self.x + self.width / 2) / zoom; - /// Pin center Y relative to node top-left, UNSCALED by zoom - out property center-y: (self.y + self.height / 2) / zoom; + /// Pin center X relative to node top-left + out property center-x: parent-offset-x + self.x + self.width / 2; + /// Pin center Y relative to node top-left + out property center-y: parent-offset-y + self.y + self.height / 2; callback drag-started(int, length, length); callback drag-moved(int, length, length); @@ -507,57 +730,58 @@ export component Pin inherits Rectangle { init => { if (pin-id > 0) { - report-position(pin-id, node-id, pin-type, center-x, center-y); + GeometryVersion.version += 1; + GeometryCallbacks.report-pin-position(pin-id, node-id, pin-type, center-x, center-y); } } changed pin-id => { if (pin-id > 0) { - report-position(pin-id, node-id, pin-type, center-x, center-y); + GeometryCallbacks.report-pin-position(pin-id, node-id, pin-type, center-x, center-y); } } changed center-x => { if (pin-id > 0) { - report-position(pin-id, node-id, pin-type, center-x, center-y); + GeometryCallbacks.report-pin-position(pin-id, node-id, pin-type, center-x, center-y); } } changed center-y => { if (pin-id > 0) { - report-position(pin-id, node-id, pin-type, center-x, center-y); - } - } - changed zoom => { - if (pin-id > 0) { - report-position(pin-id, node-id, pin-type, center-x, center-y); + GeometryCallbacks.report-pin-position(pin-id, node-id, pin-type, center-x, center-y); } } changed refresh-trigger => { if (pin-id > 0) { - report-position(pin-id, node-id, pin-type, center-x, center-y); + GeometryCallbacks.report-pin-position(pin-id, node-id, pin-type, center-x, center-y); } } touch := TouchArea { - property drag-active: false; - - pointer-event(event) => { - if event.kind == PointerEventKind.down && event.button == PointerEventButton.left { - drag-active = true; - // These are for visual link preview, should be screen-space - // root.x/root.y = Pin's position within the node layout - root.drag-started(pin-id, node-screen-x + root.x + self.x + self.width/2, node-screen-y + root.y + self.y + self.height/2); - } - if event.kind == PointerEventKind.up && drag-active { - drag-active = false; - root.drag-ended(pin-id, - node-screen-x + root.x + self.mouse-x, - node-screen-y + root.y + self.mouse-y); + moved => { + if LinkCreation.state > 0 { + // Include parent-offset in screen coordinate calculation so examples don't need zoom math + LinkCreation.current-x = node-screen-x + (parent-offset-x + root.x + self.mouse-x) * ViewportState.zoom; + LinkCreation.current-y = node-screen-y + (parent-offset-y + root.y + self.mouse-y) * ViewportState.zoom; + LinkCreation.state = 2; // updating + LinkCreation.version += 1; } } - moved => { - if drag-active { - root.drag-moved(pin-id, - node-screen-x + root.x + self.mouse-x, - node-screen-y + root.y + self.mouse-y); + pointer-event(event) => { + if event.kind == PointerEventKind.down && event.button == PointerEventButton.left { + // Set LinkCreation state to start link creation (NodeEditor watches this) + // Pin position within node must be scaled by zoom since we're inside a scaled container + LinkCreation.start-pin-id = pin-id; + LinkCreation.current-x = node-screen-x + (parent-offset-x + root.x + self.x + self.width / 2) * ViewportState.zoom; + LinkCreation.current-y = node-screen-y + (parent-offset-y + root.y + self.y + self.height / 2) * ViewportState.zoom; + LinkCreation.state = 1; // started + LinkCreation.version += 1; + } else if event.kind == PointerEventKind.up && event.button == PointerEventButton.left { + // If we're currently creating a link (started from another pin), complete it + if LinkCreation.state > 0 { + LinkCreation.current-x = node-screen-x + (parent-offset-x + root.x + self.mouse-x) * ViewportState.zoom; + LinkCreation.current-y = node-screen-y + (parent-offset-y + root.y + self.mouse-y) * ViewportState.zoom; + LinkCreation.state = 3; // complete + LinkCreation.version += 1; + } } } } diff --git a/node-editor.slint b/node-editor.slint index 72abc5a..fcd8c19 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -30,8 +30,25 @@ // ``` // Import building blocks and re-export for users -import { PinTypes, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } from "node-editor-building-blocks.slint"; -export { PinTypes, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } +import { + PinTypes, + ViewportState, + DragState, + GeometryVersion, + GeometryCallbacks, + LinkCreation, + SelectionRequest, + NodeEditorComputations, + NodeStyleDefaults, + MinimapNode, + MinimapPosition, + LinkData, + Minimap, + Link, + BaseNode, + Pin, +} from "node-editor-building-blocks.slint"; +export { PinTypes, ViewportState, DragState, GeometryVersion, GeometryCallbacks, LinkCreation, SelectionRequest, NodeEditorComputations, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } /// Modifier key for triggering box selection over nodes/links export enum BoxSelectionModifier { @@ -59,6 +76,13 @@ export component NodeEditor { in-out property pan-y: 0px; in-out property zoom: 1.0; + // Sync viewport state to global for child components + init => { + ViewportState.zoom = root.zoom; + ViewportState.pan-x = root.pan-x; + ViewportState.pan-y = root.pan-y; + } + // === Grid properties === in property grid-spacing: 24px; in property grid-snapping: true; @@ -113,6 +137,7 @@ export component NodeEditor { // === Node drag state === out property is-dragging: root.internal-is-dragging; + out property dragged-node-id: root.internal-dragged-node-id; out property drag-offset-x: root.internal-drag-offset-x; out property drag-offset-y: root.internal-drag-offset-y; @@ -145,6 +170,8 @@ export component NodeEditor { // === Box selection modifier configuration === /// Allow box selection on click on empty area (no modifier needed) in property box-selection-on-empty: true; + /// Whether link hit-testing is enabled (set to true if compute-link-at is wired) + in property has-link-selection: false; /// Modifier key that enables box selection even over links/nodes in property box-selection-modifier: BoxSelectionModifier.ctrl; @@ -169,6 +196,7 @@ export component NodeEditor { property internal-link-end-y; property internal-is-dragging: false; + property internal-dragged-node-id: 0; property internal-drag-offset-x: 0px; property internal-drag-offset-y: 0px; property internal-drag-origin-x: 0px; @@ -178,49 +206,38 @@ export component NodeEditor { /// Called when grid needs to be regenerated (on pan/zoom/resize) callback request-grid-update(); - /// Compute pin at position, returns pin ID or 0 + /// Compute pin at position (world coordinates), returns pin ID or 0 callback compute-pin-at(/* x */ length, /* y */ length) -> int; - /// Compute link at position, returns link ID or -1 + /// Compute link at position (world coordinates), returns link ID or -1 callback compute-link-at(/* x */ length, /* y */ length) -> int; - /// Compute nodes in selection box, returns array of IDs + /// Compute nodes in selection box (world coordinates), returns array of IDs callback compute-box-selection(/* x */ length, /* y */ length, /* w */ length, /* h */ length) -> [int]; - /// Compute links in selection box, returns array of IDs + /// Compute links in selection box (world coordinates), returns array of IDs callback compute-link-box-selection(/* x */ length, /* y */ length, /* w */ length, /* h */ length) -> [int]; - /// Select a node (called on click). Rust handles the selection logic. - callback select-node(/* node-id */ int, /* shift-held */ bool); - /// Select a link (called on click). Rust handles the selection logic. callback select-link(/* link-id */ int, /* shift-held */ bool); - /// Clear all selection - callback clear-selection(); - /// Sync selection IDs to node data (called after box selection) callback sync-selection-to-nodes([int]); - /// Sync selection IDs to link data (called after box selection) - callback sync-selection-to-links([int]); + /// Notification callback when node selection changes (for application tracking) + callback node-selected(/* node-id */ int, /* shift-held */ bool); - /// Compute link preview path - callback compute-link-preview-path(/* start-x */ length, /* start-y */ length, /* end-x */ length, /* end-y */ length) -> string; + /// Notification callback when selection is cleared (for application tracking) + callback selection-cleared(); - /// Compute link path from pin IDs. Used to render links from `links` property. - /// Returns SVG path commands string, or empty string if pins not found. - /// The version parameter forces re-evaluation when geometry changes (pass geometry-version). - /// Zoom and pan are passed directly so the pure callback always uses the exact - /// viewport values that Slint is using for node positioning. - pure callback compute-link-path(/* start-pin-id */ int, /* end-pin-id */ int, /* version */ int, /* zoom */ float, /* pan-x */ float, /* pan-y */ float) -> string; + /// Sync selection IDs to link data (called after box selection) + callback sync-selection-to-links([int]); // === Callbacks TO Application (events) === callback link-requested(/* start-pin */ int, /* end-pin */ int); callback link-cancelled(); callback link-hovered(); callback selection-changed(); - callback viewport-changed(); callback delete-selected(); callback add-node-requested(); callback context-menu-requested(); @@ -236,11 +253,6 @@ export component NodeEditor { /// Note: node-id and pin-type are passed explicitly so the library doesn't prescribe any pin ID encoding scheme callback pin-position-changed(/* pin-id */ int, /* node-id */ int, /* pin-type */ int, /* rel-x */ length, /* rel-y */ length); - // === Selection Checking (implemented by application) === - // Note: version parameter forces re-evaluation when selection changes (pure callback cache invalidation) - pure callback is-selected(/* node-id */ int, /* version */ int) -> bool; - pure callback is-link-selected(/* link-id */ int, /* version */ int) -> bool; - // === Selection version counter (for dependency tracking) === // This counter is incremented whenever selection changes, allowing bindings // that reference it to be re-evaluated when selection changes. @@ -256,13 +268,13 @@ export component NodeEditor { /// Report node rectangle change. Auto-increments geometry-version. public function report-node-rect(id: int, x: length, y: length, w: length, h: length) { - root.geometry-version += 1; + GeometryVersion.version += 1; root.node-rect-changed(id, x, y, w, h); } /// Report pin position change. Auto-increments geometry-version. public function report-pin-position(pin-id: int, node-id: int, pin-type: int, rel-x: length, rel-y: length) { - root.geometry-version += 1; + GeometryVersion.version += 1; root.pin-position-changed(pin-id, node-id, pin-type, rel-x, rel-y); } @@ -282,25 +294,29 @@ export component NodeEditor { root.internal-link-start-y = y; root.internal-link-end-x = x; root.internal-link-end-y = y; - root.link-preview-path-commands = root.compute-link-preview-path(x, y, x, y); + root.link-preview-path-commands = NodeEditorComputations.compute-link-preview-path( + x / 1px, y / 1px, x / 1px, y / 1px, root.zoom, root.bezier-min-offset); } /// Update link end position during creation public function update-link-end(x: length, y: length) { root.internal-link-end-x = x; root.internal-link-end-y = y; - root.link-preview-path-commands = root.compute-link-preview-path( - root.internal-link-start-x, root.internal-link-start-y, x, y - ); + root.link-preview-path-commands = NodeEditorComputations.compute-link-preview-path( + root.internal-link-start-x / 1px, root.internal-link-start-y / 1px, + x / 1px, y / 1px, root.zoom, root.bezier-min-offset); } /// Complete link creation public function complete-link-creation() { if root.internal-is-creating-link { // Call compute-pin-at and request link if valid - // Note: We call link-requested with the result directly since Slint - // doesn't support local variables. The callback checks for pin ID != 0. - root.try-complete-link(root.compute-pin-at(root.internal-link-end-x, root.internal-link-end-y)); + // Note: Convert screen coords to world coords since pin positions are stored in world space + // world = (screen - pan) / zoom + root.try-complete-link(NodeEditorComputations.compute-pin-at( + (root.internal-link-end-x - root.pan-x) / root.zoom / 1px, + (root.internal-link-end-y - root.pan-y) / root.zoom / 1px, + root.pin-hit-radius / root.zoom / 1px)); root.internal-is-creating-link = false; } } @@ -321,8 +337,7 @@ export component NodeEditor { } /// Apply zoom with pan adjustment - takes calculated new zoom and mouse position graph coords - public function apply-zoom-with-adjustment(new-zoom: float, mouse-x: length, mouse-y: length, - graph-x: float, graph-y: float) { + public function apply-zoom-with-adjustment(new-zoom: float, mouse-x: length, mouse-y: length, graph-x: float, graph-y: float) { root.zoom = new-zoom; root.pan-x = mouse-x - graph-x * new-zoom * 1px; root.pan-y = mouse-y - graph-y * new-zoom * 1px; @@ -342,8 +357,7 @@ export component NodeEditor { mouse-x, mouse-y, (mouse-x - root.pan-x) / root.zoom / 1px, - (mouse-y - root.pan-y) / root.zoom / 1px - ); + (mouse-y - root.pan-y) / root.zoom / 1px); } /// Handle link click selection - delegates to Rust via callback @@ -351,7 +365,34 @@ export component NodeEditor { root.select-link(link-id, shift-held); } - /// Handle node click selection - delegates to Rust via callback + /// Select a node by ID. If shift-held is false, clears existing selection first. + /// For shift-select (adding to selection), the application must handle via node-selected callback. + public function select-node(node-id: int, shift-held: bool) { + if !shift-held { + // Simple single-node selection: clear and set to this node + root.selected-node-ids = [node-id]; + root.selected-link-ids = []; + NodeEditorComputations.sync-selection-to-nodes(root.selected-node-ids); + root.sync-selection-to-links([]); + root.selection-version += 1; + root.selection-changed(); + } + // Notify application (for shift-select, app handles adding to selection) + root.node-selected(node-id, shift-held); + } + + /// Clear all selection + public function clear-selection() { + root.selected-node-ids = []; + root.selected-link-ids = []; + NodeEditorComputations.sync-selection-to-nodes([]); + root.sync-selection-to-links([]); + root.selection-version += 1; + root.selection-changed(); + root.selection-cleared(); + } + + /// Handle node click selection - calls select-node public function handle-node-click(node-id: int, shift-held: bool) { root.select-node(node-id, shift-held); } @@ -363,6 +404,7 @@ export component NodeEditor { root.select-node(node-id, false); } root.internal-is-dragging = true; + root.internal-dragged-node-id = node-id; root.internal-drag-offset-x = 0px; root.internal-drag-offset-y = 0px; root.internal-drag-origin-x = world-x; @@ -384,27 +426,77 @@ export component NodeEditor { // intersections regardless of their starting position. root.node-drag-ended( root.grid-snapping ? root.snap-to-grid((root.internal-drag-origin-x + delta-x) / 1px) - root.internal-drag-origin-x / 1px : delta-x / 1px, - root.grid-snapping ? root.snap-to-grid((root.internal-drag-origin-y + delta-y) / 1px) - root.internal-drag-origin-y / 1px : delta-y / 1px - ); + root.grid-snapping ? root.snap-to-grid((root.internal-drag-origin-y + delta-y) / 1px) - root.internal-drag-origin-y / 1px : delta-y / 1px); // THEN reset visual offset (now report-rect will find model already updated) root.internal-is-dragging = false; + root.internal-dragged-node-id = 0; root.internal-drag-offset-x = 0px; root.internal-drag-offset-y = 0px; } // === Trigger updates on viewport changes === - changed pan-x => { request-grid-update(); viewport-changed(); } - changed pan-y => { request-grid-update(); viewport-changed(); } - changed zoom => { root.geometry-version += 1; request-grid-update(); viewport-changed(); } - changed width => { request-grid-update(); } - changed height => { request-grid-update(); } + changed pan-x => { + ViewportState.pan-x = root.pan-x; + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); + } + changed pan-y => { + ViewportState.pan-y = root.pan-y; + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); + } + changed zoom => { + ViewportState.zoom = root.zoom; + GeometryVersion.version += 1; + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); + } + + // Sync drag state to global + changed internal-is-dragging => { + DragState.is-dragging = root.internal-is-dragging; + } + changed internal-dragged-node-id => { + DragState.dragged-node-id = root.internal-dragged-node-id; + } + changed internal-drag-offset-x => { + DragState.drag-offset-x = root.internal-drag-offset-x; + } + changed internal-drag-offset-y => { + DragState.drag-offset-y = root.internal-drag-offset-y; + } + changed width => { + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); + } + changed height => { + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); + } + + // Watch LinkCreation global for pin-initiated link creation + property link-creation-watch: LinkCreation.version; + changed link-creation-watch => { + if LinkCreation.state == 1 { + // Started - begin link creation + root.start-link-from-pin(LinkCreation.start-pin-id, LinkCreation.current-x, LinkCreation.current-y); + } else if LinkCreation.state == 2 { + // Updating - update link end position + root.update-link-end(LinkCreation.current-x, LinkCreation.current-y); + } else if LinkCreation.state == 3 { + // Complete - try to complete link creation + root.complete-link-creation(); + LinkCreation.state = 0; // Reset to idle + } + } + + // Watch SelectionRequest global for node-initiated selection + property selection-request-watch: SelectionRequest.version; + changed selection-request-watch => { + root.select-node(SelectionRequest.node-id, SelectionRequest.shift-held); + } /// Call this after initial geometry has been reported to force link path recomputation. public function refresh-links() { - root.geometry-version += 1; + GeometryVersion.version += 1; // Also trigger viewport-changed to force a repaint - root.viewport-changed(); + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } // === Minimap Positioning === @@ -433,6 +525,7 @@ export component NodeEditor { width: 100%; height: 100%; background: root.background-color; + clip: false; // Don't clip scaled content // Grid Path { @@ -480,10 +573,8 @@ export component NodeEditor { // Check if box selection should start based on configuration // Modifier key can force box selection even over links // Empty area allows box selection if box-selection-on-empty is true - if (root.box-selection-modifier == BoxSelectionModifier.ctrl && event.modifiers.control) || - (root.box-selection-modifier == BoxSelectionModifier.alt && event.modifiers.alt) || - (root.box-selection-modifier == BoxSelectionModifier.shift && event.modifiers.shift) || - (root.box-selection-on-empty && root.compute-link-at(self.mouse-x, self.mouse-y) < 0) { + // If has-link-selection is false, skip link hit testing entirely + if (root.box-selection-modifier == BoxSelectionModifier.ctrl && event.modifiers.control) || (root.box-selection-modifier == BoxSelectionModifier.alt && event.modifiers.alt) || (root.box-selection-modifier == BoxSelectionModifier.shift && event.modifiers.shift) || (root.box-selection-on-empty && (!root.has-link-selection || root.compute-link-at((self.mouse-x - root.pan-x) / root.zoom, (self.mouse-y - root.pan-y) / root.zoom) < 0)) { // Start box selection root.internal-is-box-selecting = true; root.box-start-x = self.mouse-x; @@ -495,9 +586,9 @@ export component NodeEditor { root.selected-link-ids = []; root.clear-selection(); } - } else if root.compute-link-at(self.mouse-x, self.mouse-y) >= 0 { + } else if root.has-link-selection && root.compute-link-at((self.mouse-x - root.pan-x) / root.zoom, (self.mouse-y - root.pan-y) / root.zoom) >= 0 { // Clicked on a link - select it - root.select-link(root.compute-link-at(self.mouse-x, self.mouse-y), event.modifiers.shift); + root.select-link(root.compute-link-at((self.mouse-x - root.pan-x) / root.zoom, (self.mouse-y - root.pan-y) / root.zoom), event.modifiers.shift); } } else if event.button == PointerEventButton.right { root.context-menu-x = self.mouse-x; @@ -511,17 +602,21 @@ export component NodeEditor { } if root.internal-is-box-selecting && event.button == PointerEventButton.left { root.internal-is-box-selecting = false; - // Compute selected nodes and links - root.selected-node-ids = root.compute-box-selection( - root.selection-x, root.selection-y, - root.selection-width, root.selection-height - ); + // Convert selection box from screen to world coordinates for node hit testing + // For nodes: world = (screen - pan) / zoom + root.selected-node-ids = NodeEditorComputations.compute-box-selection( + (root.selection-x - root.pan-x) / root.zoom / 1px, + (root.selection-y - root.pan-y) / root.zoom / 1px, + root.selection-width / root.zoom / 1px, + root.selection-height / root.zoom / 1px); root.selected-link-ids = root.compute-link-box-selection( - root.selection-x, root.selection-y, - root.selection-width, root.selection-height - ); - root.sync-selection-to-nodes(root.selected-node-ids); + (root.selection-x - root.pan-x) / root.zoom, + (root.selection-y - root.pan-y) / root.zoom, + root.selection-width / root.zoom, + root.selection-height / root.zoom); + NodeEditorComputations.sync-selection-to-nodes(root.selected-node-ids); root.sync-selection-to-links(root.selected-link-ids); + root.selection-version += 1; root.selection-changed(); } if root.internal-is-creating-link && event.button == PointerEventButton.left { @@ -530,13 +625,17 @@ export component NodeEditor { } if event.kind == PointerEventKind.move { // Link hover detection when not doing other interactions - if !root.internal-is-panning && !root.internal-is-box-selecting && !root.internal-is-creating-link { - root.update-link-hover(root.compute-link-at(self.mouse-x, self.mouse-y)); + if root.has-link-selection && !root.internal-is-panning && !root.internal-is-box-selecting && !root.internal-is-creating-link { + root.update-link-hover(root.compute-link-at((self.mouse-x - root.pan-x) / root.zoom, (self.mouse-y - root.pan-y) / root.zoom)); } } } moved => { + // Update screen mouse coordinates (unaffected by transform-scale) + ViewportState.screen-mouse-x = self.mouse-x; + ViewportState.screen-mouse-y = self.mouse-y; + if root.internal-is-panning { root.pan-x = root.pan-start-offset-x + (self.mouse-x - root.pan-start-mouse-x); root.pan-y = root.pan-start-offset-y + (self.mouse-y - root.pan-start-mouse-y); @@ -560,20 +659,33 @@ export component NodeEditor { } } - // === Layer 2: Links and user children (nodes) === + // === Layer 2: World Content (scaled) === + // Container positioned at pan offset with transform-scale for zoom. + // Nodes use pure world coordinates (x: world-x, y: world-y). + // After transform: screen = pan + world * zoom ✓ + world-content := Rectangle { + x: root.pan-x; + y: root.pan-y; + width: 100000px; // Large enough for any world content + height: 100000px; + transform-origin: { x: 0, y: 0 }; + transform-scale: root.zoom; + background: transparent; + clip: false; + + // Render links from `links` property + for link in root.links: Link { + path-commands: NodeEditorComputations.compute-link-path(link.start-pin-id, link.end-pin-id, GeometryVersion.version, root.zoom, root.pan-x / 1px, root.pan-y / 1px); + link-color: link.color; + line-width: link.line-width > 0 ? link.line-width * 1px : 2px; + selected: NodeEditorComputations.is-link-selected(link.id, root.selection-version); + hovered: root.hovered-link-id == link.id; + } - // Render links from `links` property (new API) - for link in root.links: Link { - path-commands: root.compute-link-path(link.start-pin-id, link.end-pin-id, root.geometry-version, root.zoom, root.pan-x / 1px, root.pan-y / 1px); - link-color: link.color; - line-width: link.line-width > 0 ? link.line-width * 1px : 2px; - selected: root.is-link-selected(link.id, root.selection-version); - hovered: root.hovered-link-id == link.id; + // User-provided children (nodes) + @children } - // User-provided children (nodes) - @children - // === Layer 3: Overlays === // Selection box diff --git a/src/callbacks.rs b/src/callbacks.rs new file mode 100644 index 0000000..a7aca4f --- /dev/null +++ b/src/callbacks.rs @@ -0,0 +1,53 @@ +//! Automatic callback wiring for node editor applications. +//! +//! This module provides a helper function to automatically wire up internal +//! geometry callbacks, eliminating boilerplate from examples. + +use slint::ComponentHandle; + +/// Automatically wire up all internal geometry callbacks for a node editor window. +/// +/// This function connects the GeometryCallbacks global to the NodeEditor's internal +/// functions, eliminating the need for examples to manually forward callbacks. +/// +/// **Call this once during application setup!** +/// +/// # Example +/// +/// ```ignore +/// use slint_node_editor::setup_node_editor_callbacks; +/// +/// slint::include_modules!(); +/// +/// fn main() { +/// let window = MainWindow::new().unwrap(); +/// +/// // One-line setup - wires everything automatically! +/// setup_node_editor_callbacks(&window); +/// +/// // Now just set up data and run +/// window.set_nodes(...); +/// window.run().unwrap(); +/// } +/// ``` +pub fn setup_node_editor_callbacks(window: &T) +where + T: ComponentHandle, +{ + // Note: This function cannot be implemented generically because: + // 1. Slint's global() method requires a concrete type known at compile time + // 2. The GeometryCallbacks global is defined in the application's .slint files + // 3. Each application has its own generated types + // + // Instead, applications should call window.global::() and + // wire callbacks directly, or we provide a macro to generate this boilerplate. + // + // For now, this function documents the intended pattern but applications must + // implement the wiring themselves until we create a macro solution. + + let _ = window; // Suppress warning + unimplemented!( + "setup_node_editor_callbacks cannot be implemented generically. \ + See the minimal example for the pattern to use." + ); +} diff --git a/src/controller.rs b/src/controller.rs index e06a57e..f6964ec 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -171,9 +171,8 @@ impl NodeEditorController { /// Returns a callback for `compute-link-path`. /// - /// Computes screen-space bezier paths from world-space cache data. - /// Zoom and pan are passed directly from Slint to guarantee they match - /// the values used for node positioning (eliminates sync issues). + /// Computes bezier paths for use inside a scaled container at origin. + /// Positions are calculated so that after transform-scale, links appear at correct screen position. pub fn compute_link_path_callback(&self) -> impl Fn(i32, i32, i32, f32, f32, f32) -> SharedString { let cache = self.cache.clone(); let state = self.state.clone(); @@ -181,7 +180,7 @@ impl NodeEditorController { let s = state.borrow(); cache .borrow() - .compute_link_path_screen( + .compute_link_path_world( start_pin, end_pin, zoom, @@ -204,21 +203,16 @@ impl NodeEditorController { // === Direct handlers === - /// Handle node-rect-changed: convert screen→world and update cache. + /// Handle node-rect-changed: update cache with world coordinates. /// - /// The UI reports node rects in screen coordinates. This method converts - /// to world coordinates before caching, making the cache zoom/pan invariant. + /// The UI now reports node rects in world coordinates (BaseNode positions + /// itself using world-x + drag-offset-x, without zoom/pan conversion). + /// No conversion needed - values are stored directly. pub fn handle_node_rect(&self, id: i32, x: f32, y: f32, w: f32, h: f32) { - let s = self.state.borrow(); - let z = s.safe_zoom(); - let world_x = (x - s.pan_x) / z; - let world_y = (y - s.pan_y) / z; - let world_w = w / z; - let world_h = h / z; - drop(s); + // Since nodes now report world coordinates directly, no conversion needed self.cache .borrow_mut() - .handle_node_rect_report(id, world_x, world_y, world_w, world_h); + .handle_node_rect_report(id, x, y, w, h); } /// Handle pin-position-changed: update cache. @@ -559,13 +553,14 @@ mod tests { } // ======================================================================== - // handle_node_rect: screen→world conversion + // handle_node_rect: world coordinates stored directly (no conversion) // ======================================================================== #[test] fn test_handle_node_rect_zoom1() { let ctrl = NodeEditorController::new(); ctrl.set_viewport(1.0, 0.0, 0.0); + // World coords passed directly - stored as-is ctrl.handle_node_rect(1, 100.0, 200.0, 50.0, 30.0); let cache = ctrl.cache.borrow(); let rect = cache.node_rects.get(&1).unwrap().rect(); @@ -576,20 +571,19 @@ mod tests { fn test_handle_node_rect_zoom2_with_pan() { let ctrl = NodeEditorController::new(); ctrl.set_viewport(2.0, 50.0, 100.0); - // Screen coords: x=250, y=300, w=100, h=60 - // World: x=(250-50)/2=100, y=(300-100)/2=100, w=50, h=30 - ctrl.handle_node_rect(1, 250.0, 300.0, 100.0, 60.0); + // World coords passed directly - stored as-is regardless of zoom/pan + ctrl.handle_node_rect(1, 100.0, 100.0, 50.0, 30.0); let cache = ctrl.cache.borrow(); let rect = cache.node_rects.get(&1).unwrap().rect(); assert_eq!(rect, (100.0, 100.0, 50.0, 30.0)); } #[test] - fn test_handle_node_rect_zero_zoom_fallback() { + fn test_handle_node_rect_zero_zoom() { let ctrl = NodeEditorController::new(); ctrl.set_viewport(0.0, 0.0, 0.0); + // World coords passed directly - stored as-is ctrl.handle_node_rect(1, 100.0, 200.0, 50.0, 30.0); - // Should use zoom=1.0 fallback let cache = ctrl.cache.borrow(); let rect = cache.node_rects.get(&1).unwrap().rect(); assert_eq!(rect, (100.0, 200.0, 50.0, 30.0)); diff --git a/src/lib.rs b/src/lib.rs index 003d5a4..289d2b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,7 @@ pub mod graph; pub mod tracking; pub mod links; pub mod controller; +pub mod setup; #[cfg(feature = "layout")] pub mod layout; @@ -77,5 +78,91 @@ pub use graph::{ pub use tracking::GeometryTracker; pub use links::LinkManager; pub use controller::NodeEditorController; +pub use setup::NodeEditorSetup; #[cfg(feature = "layout")] pub use layout::{sugiyama_layout, sugiyama_layout_from_cache, Direction, NodePosition, SugiyamaConfig}; + +/// Wire up all NodeEditor callbacks with a single macro call. +/// +/// This macro sets up default behavior for geometry tracking, computations, and grid updates. +/// You can override any callback after calling this macro - the last `.on_*()` call wins. +/// +/// # Example +/// +/// ```ignore +/// use slint_node_editor::{NodeEditorSetup, wire_node_editor}; +/// +/// let setup = NodeEditorSetup::new(|node_id, dx, dy| { +/// // Update your model +/// }); +/// +/// wire_node_editor!(window, setup); +/// +/// // Override specific callbacks if needed: +/// // window.global::().on_compute_pin_at(|x, y, radius| { ... }); +/// ``` +#[macro_export] +macro_rules! wire_node_editor { + ($window:expr, $setup:expr) => {{ + // Geometry tracking + let gc = $window.global::(); + gc.on_report_node_rect($setup.report_node_rect()); + gc.on_report_pin_position($setup.report_pin_position()); + gc.on_start_node_drag($setup.start_node_drag()); + gc.on_end_node_drag($setup.end_node_drag()); + + // Computations + let computations = $window.global::(); + computations.on_compute_link_path($setup.compute_link_path()); + + let ctrl = $setup.controller().clone(); + computations.on_compute_pin_at(move |x, y, radius| { + ctrl.cache().borrow().find_pin_at(x, y, radius) + }); + + computations.on_compute_link_preview_path(|start_x, start_y, end_x, end_y, zoom, bezier_offset| { + slint_node_editor::generate_bezier_path(start_x, start_y, end_x, end_y, zoom, bezier_offset).into() + }); + + let ctrl = $setup.controller().clone(); + computations.on_compute_box_selection(move |x, y, w, h| { + let ids = ctrl.cache().borrow().nodes_in_selection_box(x, y, w, h); + ids.as_slice().into() + }); + + // Selection state tracking - uses setup's internal selection set + let selection_set = $setup.selection(); + + let sm_check = selection_set.clone(); + computations.on_is_node_selected(move |id, _version| sm_check.borrow().contains(&id)); + + let sm_update = selection_set.clone(); + computations.on_sync_selection_to_nodes(move |ids_model| { + use slint::Model; + let mut set = sm_update.borrow_mut(); + set.clear(); + for i in 0..ids_model.row_count() { + if let Some(id) = ids_model.row_data(i) { + set.insert(id); + } + } + }); + + // Auto grid updates + let ctrl = $setup.controller().clone(); + let w = $window.as_weak(); + $window.global::().on_viewport_changed(move |zoom, pan_x, pan_y| { + ctrl.set_viewport(zoom, pan_x, pan_y); + if let Some(w) = w.upgrade() { + w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); + } + }); + + // Initial grid + let ctrl = $setup.controller().clone(); + let w = $window.as_weak(); + if let Some(w) = w.upgrade() { + w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); + } + }}; +} diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..953cce6 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,164 @@ +//! Simplified setup helpers for NodeEditor with globals architecture. +//! +//! The [`NodeEditorSetup`] provides automatic callback handling. You only need +//! to provide a closure that updates your model when nodes are moved. +//! +//! # Example +//! +//! ```ignore +//! use slint_node_editor::NodeEditorSetup; +//! use slint::{Model, VecModel}; +//! use std::rc::Rc; +//! +//! slint::include_modules!(); +//! +//! fn main() { +//! let window = MainWindow::new().unwrap(); +//! let nodes = Rc::new(VecModel::from(vec![/* your nodes */])); +//! +//! // Create setup with your model update logic +//! let setup = NodeEditorSetup::new({ +//! let nodes = nodes.clone(); +//! move |node_id, delta_x, delta_y| { +//! for i in 0..nodes.row_count() { +//! if let Some(mut node) = nodes.row_data(i) { +//! if node.id == node_id { +//! node.x += delta_x; +//! node.y += delta_y; +//! nodes.set_row_data(i, node); +//! break; +//! } +//! } +//! } +//! } +//! }); +//! +//! // Wire callbacks (5 lines for complete setup) +//! let gc = window.global::(); +//! gc.on_report_node_rect(setup.report_node_rect()); +//! gc.on_report_pin_position(setup.report_pin_position()); +//! gc.on_start_node_drag(setup.start_node_drag()); +//! gc.on_end_node_drag(setup.end_node_drag()); +//! window.global::().on_compute_link_path(setup.compute_link_path()); +//! +//! window.run().unwrap(); +//! } +//! ``` + +use crate::controller::NodeEditorController; +use std::cell::RefCell; +use std::collections::HashSet; +use std::rc::Rc; + +/// Setup helper that bundles NodeEditorController and automatic model updates. +/// +/// This helper eliminates boilerplate by: +/// - Managing the controller lifecycle +/// - Tracking dragged node ID internally +/// - Tracking selection for multi-node drag +/// - Calling your model-update closure automatically on drag end +pub struct NodeEditorSetup +where + F: Fn(i32, f32, f32) + 'static, +{ + controller: Rc, + dragged_node_id: Rc>, + selection: Rc>>, + on_node_moved: Rc, +} + +impl NodeEditorSetup +where + F: Fn(i32, f32, f32) + 'static, +{ + /// Create a new setup helper with a node-moved callback. + /// + /// The callback receives `(node_id, delta_x, delta_y)` when a node drag ends. + /// This is the ONLY callback you need to provide - everything else is handled internally. + pub fn new(on_node_moved: F) -> Self { + Self { + controller: Rc::new(NodeEditorController::new()), + dragged_node_id: Rc::new(RefCell::new(0i32)), + selection: Rc::new(RefCell::new(HashSet::new())), + on_node_moved: Rc::new(on_node_moved), + } + } + + /// Get access to the underlying controller for advanced operations. + pub fn controller(&self) -> &NodeEditorController { + &self.controller + } + + /// Get access to the selection set for the macro to wire up. + pub fn selection(&self) -> Rc>> { + self.selection.clone() + } + + /// Callback for `GeometryCallbacks.on_report_node_rect`. + pub fn report_node_rect(&self) -> impl Fn(i32, f32, f32, f32, f32) + 'static { + let ctrl = self.controller.clone(); + move |id, x, y, w, h| { + ctrl.handle_node_rect(id, x, y, w, h); + } + } + + /// Callback for `GeometryCallbacks.on_report_pin_position`. + pub fn report_pin_position(&self) -> impl Fn(i32, i32, i32, f32, f32) + 'static { + let ctrl = self.controller.clone(); + move |pin_id, node_id, pin_type, x, y| { + ctrl.handle_pin_position(pin_id, node_id, pin_type, x, y); + } + } + + /// Callback for `GeometryCallbacks.on_start_node_drag`. + pub fn start_node_drag(&self) -> impl Fn(i32, bool, f32, f32) + 'static { + let dragged = self.dragged_node_id.clone(); + move |node_id, _, _, _| { + *dragged.borrow_mut() = node_id; + } + } + + /// Callback for `GeometryCallbacks.on_end_node_drag`. + /// + /// This automatically calls your model-update closure with the dragged node ID. + /// If the dragged node is part of a multi-node selection, all selected nodes are moved. + pub fn end_node_drag(&self) -> impl Fn(f32, f32) + 'static { + let dragged = self.dragged_node_id.clone(); + let selection = self.selection.clone(); + let on_moved = self.on_node_moved.clone(); + move |delta_x, delta_y| { + let node_id = *dragged.borrow(); + let sel = selection.borrow(); + + // If dragged node is in a multi-node selection, move all selected nodes + if sel.contains(&node_id) && sel.len() > 1 { + for &id in sel.iter() { + on_moved(id, delta_x, delta_y); + } + } else { + // Single node drag + on_moved(node_id, delta_x, delta_y); + } + } + } + + /// Callback for `NodeEditorComputations.on_compute_link_path`. + pub fn compute_link_path(&self) -> impl Fn(i32, i32, i32, f32, f32, f32) -> slint::SharedString + 'static { + self.controller.compute_link_path_callback() + } + + /// Generate the initial grid commands. + pub fn generate_initial_grid(&self, width: f32, height: f32) -> slint::SharedString { + self.controller.generate_initial_grid(width, height) + } + + /// Generate grid commands for the current viewport. + pub fn generate_grid(&self, width: f32, height: f32, pan_x: f32, pan_y: f32) -> slint::SharedString { + self.controller.generate_grid(width, height, pan_x, pan_y) + } + + /// Set the viewport state. + pub fn set_viewport(&self, zoom: f32, pan_x: f32, pan_y: f32) { + self.controller.set_viewport(zoom, pan_x, pan_y); + } +} diff --git a/src/state.rs b/src/state.rs index 64539d1..f0900bf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -201,6 +201,42 @@ where Some(generate_bezier_path(sx, sy, ex, ey, zoom, bezier_min_offset)) } + /// Compute bezier path in pure world coordinates. + /// + /// Used when links are rendered inside a container that is: + /// - Positioned at (pan-x, pan-y) + /// - Has transform-scale: zoom with transform-origin at (0, 0) + /// + /// In this setup, a child at position (x, y) appears on screen at: + /// screen_x = pan_x + x * zoom + /// screen_y = pan_y + y * zoom + /// + /// So we just use pure world coordinates for the path. + pub fn compute_link_path_world( + &self, + start_pin: i32, + end_pin: i32, + _zoom: f32, // Not used - kept for API compatibility + _pan_x: f32, // Not used - kept for API compatibility + _pan_y: f32, // Not used - kept for API compatibility + bezier_min_offset: f32, + ) -> Option { + let start_pos = self.pin_positions.get(&start_pin)?; + let end_pos = self.pin_positions.get(&end_pin)?; + + let start_rect = self.node_rects.get(&start_pos.node_id)?.rect(); + let end_rect = self.node_rects.get(&end_pos.node_id)?.rect(); + + // Pure world coordinates + let sx = start_rect.0 + start_pos.rel_x; + let sy = start_rect.1 + start_pos.rel_y; + let ex = end_rect.0 + end_pos.rel_x; + let ey = end_rect.1 + end_pos.rel_y; + + // Use zoom=1.0 for bezier offset since scaling is handled by container transform + Some(generate_bezier_path(sx, sy, ex, ey, 1.0, bezier_min_offset)) + } + /// Standard handler for pin position reports from Slint pub fn handle_pin_report( &mut self, diff --git a/tests/common/harness.rs b/tests/common/harness.rs index 8f4def6..96e88a6 100644 --- a/tests/common/harness.rs +++ b/tests/common/harness.rs @@ -91,8 +91,11 @@ impl MinimalTestHarness { let links = Rc::new(VecModel::from(links)); window.set_links(ModelRc::from(links.clone())); + // Wire global computational callbacks + let computations = window.global::(); + // Core callbacks - controller handles the logic - window.on_compute_link_path(ctrl.compute_link_path_callback()); + computations.on_compute_link_path(ctrl.compute_link_path_callback()); window.on_node_drag_started({ let ctrl = ctrl.clone(); let tracker = tracker.clone(); @@ -102,8 +105,8 @@ impl MinimalTestHarness { } }); - // Geometry tracking - update cache - window.on_node_rect_changed({ + // Geometry tracking via globals + window.global::().on_report_node_rect({ let ctrl = ctrl.clone(); let tracker = tracker.clone(); move |id, x, y, width, h| { @@ -112,7 +115,7 @@ impl MinimalTestHarness { } }); - window.on_pin_position_changed({ + window.global::().on_report_pin_position({ let ctrl = ctrl.clone(); let tracker = tracker.clone(); move |pid, nid, ptype, x, y| { @@ -132,7 +135,7 @@ impl MinimalTestHarness { } }); - window.on_update_viewport({ + computations.on_viewport_changed({ let ctrl = ctrl.clone(); let tracker = tracker.clone(); let w = w.clone(); @@ -214,39 +217,42 @@ impl MinimalTestHarness { } }); - // Selection callbacks - window.on_select_node({ + // Selection notification callbacks + // NodeEditor handles basic selection internally. These are for tracking and shift-select. + window.on_node_selected({ let selection = selection.clone(); let w = w.clone(); move |node_id, shift_held| { - let mut sel = selection.borrow_mut(); - sel.handle_interaction(node_id, shift_held); - if let Some(w) = w.upgrade() { - let ids: Vec = sel.iter().cloned().collect(); - w.set_selected_node_ids(ModelRc::from(Rc::new(VecModel::from(ids)))); - w.set_selection_version(w.get_selection_version() + 1); + if shift_held { + // For shift-select, app must handle adding to selection + let mut sel = selection.borrow_mut(); + sel.handle_interaction(node_id, true); + if let Some(w) = w.upgrade() { + let ids: Vec = sel.iter().cloned().collect(); + w.set_selected_node_ids(ModelRc::from(Rc::new(VecModel::from(ids)))); + w.set_selection_version(w.get_selection_version() + 1); + } } + // For non-shift, NodeEditor already updated selection } }); - window.on_clear_selection({ - let selection = selection.clone(); - let w = w.clone(); + window.on_selection_cleared({ + let _selection = selection.clone(); move || { - selection.borrow_mut().clear(); - if let Some(w) = w.upgrade() { - w.set_selected_node_ids(ModelRc::default()); - w.set_selection_version(w.get_selection_version() + 1); - } + // NodeEditor already cleared node selection + // App can do additional cleanup here if needed } }); - window.on_is_selected({ + // is-node-selected now uses the global + computations.on_is_node_selected({ let selection = selection.clone(); move |node_id, _version| selection.borrow().contains(node_id) }); - window.on_sync_selection_to_nodes({ + // Node selection sync now uses the global + computations.on_sync_selection_to_nodes({ let selection = selection.clone(); move |ids| { let mut sel = selection.borrow_mut(); @@ -277,18 +283,6 @@ impl MinimalTestHarness { } }); - window.on_compute_box_selection({ - let ctrl = ctrl.clone(); - move |x, y, w, h| { - let ids = ctrl.cache().borrow().nodes_in_selection_box(x, y, w, h); - ModelRc::from(Rc::new(VecModel::from(ids))) - } - }); - - window.on_compute_link_preview_path(|start_x, start_y, end_x, end_y| { - slint_node_editor::generate_bezier_path(start_x, start_y, end_x, end_y, 1.0, 50.0).into() - }); - // Initialize grid window.invoke_request_grid_update(); diff --git a/tests/ui/test.slint b/tests/ui/test.slint index 49aba09..803997b 100644 --- a/tests/ui/test.slint +++ b/tests/ui/test.slint @@ -1,7 +1,16 @@ // Test UI for integration testing // Extends minimal example with all callbacks exposed for testing -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + LinkData, + ViewportState, + GeometryCallbacks, + NodeEditorComputations, +} from "@slint-node-editor/node-editor.slint"; export struct NodeData { id: int, @@ -10,58 +19,50 @@ export struct NodeData { y: float, } -// Re-export LinkData for Rust -export { LinkData } +// Re-export for Rust +export { LinkData, GeometryCallbacks, NodeEditorComputations } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 150px * self.zoom; - height: 100px * self.zoom; + node-width: 150px; + node-height: 100px; Rectangle { + width: 100%; + height: 100%; background: root.selected ? #4a6a9a : #333; - border-radius: 4px * root.zoom; - border-width: 1px * root.zoom; + border-radius: 4px; + border-width: 1px; border-color: #666; Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px; } } // Single input pin Pin { x: 0px; - y: parent.height / 2 - self.height / 2; + y: root.node-height / 2 - self.height / 2; pin-id: root.node-id * 2; node-id: root.node-id; pin-type: PinTypes.input; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } // Single output pin Pin { - x: parent.width - self.width; - y: parent.height / 2 - self.height / 2; + x: root.node-width - self.width; + y: root.node-height / 2 - self.height / 2; pin-id: root.node-id * 2 + 1; node-id: root.node-id; pin-type: PinTypes.output; - zoom: root.zoom; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-position(id, nid, type, x, y) => { - root.report-position(id, nid, type, x, y); - } } - - callback report-position(int, int, int, length, length); } export component MainWindow inherits Window { @@ -72,7 +73,6 @@ export component MainWindow inherits Window { in property <[NodeData]> nodes; in property <[LinkData]> links <=> editor.links; in-out property grid_commands <=> editor.grid-commands; - in-out property dragged-node-id: 0; // Size for grid generation out property width_: self.width / 1px; @@ -84,13 +84,9 @@ export component MainWindow inherits Window { in-out property selection-version <=> editor.selection-version; // Core callbacks to Rust - callback node-rect-changed <=> editor.node-rect-changed; - callback pin-position-changed <=> editor.pin-position-changed; callback request-grid-update <=> editor.request-grid-update; - callback update-viewport(/* zoom */ float, /* pan-x */ float, /* pan-y */ float); callback node-drag-started <=> editor.node-drag-started; callback node-drag-ended <=> editor.node-drag-ended; - pure callback compute-link-path <=> editor.compute-link-path; // Additional callbacks for testing callback link-requested <=> editor.link-requested; @@ -100,21 +96,16 @@ export component MainWindow inherits Window { callback context-menu-requested <=> editor.context-menu-requested; callback add-node-requested <=> editor.add-node-requested; - // Selection callbacks - callback select-node <=> editor.select-node; + // Selection callbacks (node-selected and selection-cleared are notifications) + callback node-selected <=> editor.node-selected; callback select-link <=> editor.select-link; - callback clear-selection <=> editor.clear-selection; - callback sync-selection-to-nodes <=> editor.sync-selection-to-nodes; + callback selection-cleared <=> editor.selection-cleared; callback sync-selection-to-links <=> editor.sync-selection-to-links; - pure callback is-selected <=> editor.is-selected; - pure callback is-link-selected <=> editor.is-link-selected; - // Compute callbacks + // Compute callbacks (tests use globals instead of these) callback compute-pin-at <=> editor.compute-pin-at; callback compute-link-at <=> editor.compute-link-at; - callback compute-box-selection <=> editor.compute-box-selection; callback compute-link-box-selection <=> editor.compute-link-box-selection; - callback compute-link-preview-path <=> editor.compute-link-preview-path; // Function to refresh links after geometry is reported public function refresh-links() { @@ -125,38 +116,13 @@ export component MainWindow inherits Window { width: 100%; height: 100%; - viewport-changed => { - root.update-viewport(self.zoom, self.pan-x / 1px, self.pan-y / 1px); - } - for data in root.nodes: SimpleNode { node-id: data.id; title: data.title; world-x: data.x * 1px; world-y: data.y * 1px; - zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - drag-offset-x: data.id == root.dragged-node-id ? editor.drag-offset-x : 0px; - drag-offset-y: data.id == root.dragged-node-id ? editor.drag-offset-y : 0px; - - report-rect(id, x, y, w, h) => { - editor.report-node-rect(id, x, y, w, h); - } - report-position(pid, nid, pt, x, y) => { - editor.report-pin-position(pid, nid, pt, x, y); - } - drag-started(id, already-selected, wx, wy) => { - root.dragged-node-id = id; - editor.start-node-drag(id, already-selected, wx, wy); - } - drag-moved(id, dx, dy) => { - editor.update-node-drag(dx, dy); - } - drag-ended(id, dx, dy) => { - root.dragged-node-id = 0; - editor.end-node-drag(dx, dy); - } + viewport-width: editor.width; + viewport-height: editor.height; } } }