From 48497d6fafe28cfd3fe093d1b8705c65889fd7e7 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 18 Mar 2026 10:22:04 +0000 Subject: [PATCH 01/22] refactor zoom using transform-scale --- examples/minimal/ui/minimal.slint | 46 ++++++++++----- node-editor-building-blocks.slint | 90 ++++++++++++++++++++---------- node-editor.slint | 93 ++++++++++++++++++++----------- src/controller.rs | 31 ++++------- src/state.rs | 27 +++++++++ 5 files changed, 192 insertions(+), 95 deletions(-) diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index 82070c4..752d4fb 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -1,4 +1,10 @@ -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + LinkData, +} from "@slint-node-editor/node-editor.slint"; export struct NodeData { id: int, @@ -12,47 +18,59 @@ export { LinkData } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 150px * self.zoom; - height: 100px * self.zoom; + // Set visual size on BaseNode for proper hit-testing/reporting + visual-width: 150px; + visual-height: 100px; + // Total size includes culling margin on all sides + width: self.visual-width + self.culling-margin * 2; + height: self.visual-height + self.culling-margin * 2; + + // Visual content wrapper - offset by culling-margin so it appears at the correct position Rectangle { + x: root.culling-margin; + y: root.culling-margin; + width: root.visual-width; + height: root.visual-height; 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 + // Single input pin - offset by culling-margin Pin { - x: 0px; - y: parent.height / 2 - self.height / 2; + x: root.culling-margin; + y: root.culling-margin + root.visual-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-offset-x: root.culling-margin; + report-offset-y: root.culling-margin; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } } - // Single output pin + // Single output pin - offset by culling-margin Pin { - x: parent.width - self.width; - y: parent.height / 2 - self.height / 2; + x: root.culling-margin + root.visual-width - self.width; + y: root.culling-margin + root.visual-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-offset-x: root.culling-margin; + report-offset-y: root.culling-margin; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 859e426..273a85d 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -209,8 +209,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,18 +311,35 @@ export component Link inherits Path { } /// Base component for all node types in the node editor. +/// Nodes are positioned in WORLD coordinates - the parent NodeEditor applies +/// transform-scale for zoom, so no zoom multiplication is needed here. +/// +/// Culling: To prevent premature culling near screen edges, set `culling-margin` +/// to extend the element's bounds. The visual content should be placed inside +/// a child element positioned at (culling-margin, culling-margin). 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 zoom: 1.0; // Still passed for coordinate conversions + in property pan-x: 0px; // Still passed for coordinate conversions in property pan-y: 0px; in property drag-offset-x: 0px; in property drag-offset-y: 0px; + // === Culling Margin === + // Extra margin around node bounds to prevent premature culling when using transform-scale. + // Set to 0px for no margin (old behavior). Visual content should be offset by this amount. + in property culling-margin: 500px; + + // === Visual Size === + // The size of the actual visual content (for reporting to Rust for hit-testing). + // Children should set these to match their visual content dimensions. + in property visual-width: 150px; + in property visual-height: 100px; + // === Styling Properties (overridable) === in property background-color: #2d2d2d; in property background-color-selected: #3a3a4a; @@ -346,48 +362,52 @@ 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 positioning inside scaled container) + property pos-x: world-x + drag-offset-x; + property pos-y: world-y + drag-offset-y; + // Screen position (for coordinate conversions when needed) + out property screen-x: pos-x * zoom + pan-x; + out property screen-y: pos-y * zoom + 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 + // Position offset by culling-margin so bounds extend earlier for culling detection + x: pos-x - culling-margin; + y: pos-y - culling-margin; + // BaseNode is transparent - visual styling should be in child elements + background: transparent; + clip: false; + + // Report rect on changes (in WORLD coordinates, using visual size for hit-testing) init => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + report-rect(node-id, pos-x, pos-y, visual-width, visual-height); } } changed node-id => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + report-rect(node-id, pos-x, pos-y, visual-width, visual-height); } } changed drag-offset-x => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + report-rect(node-id, pos-x, pos-y, visual-width, visual-height); } } changed drag-offset-y => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + report-rect(node-id, pos-x, pos-y, visual-width, visual-height); } } changed width => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + report-rect(node-id, pos-x, pos-y, visual-width, visual-height); } } changed height => { if (node-id > 0) { - report-rect(node-id, screen-x, screen-y, self.width, self.height); + report-rect(node-id, pos-x, pos-y, visual-width, visual-height); } } @@ -395,11 +415,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 covers only the visual content area (not the culling margin) TouchArea { + x: root.culling-margin; + y: root.culling-margin; + width: root.visual-width; + height: root.visual-height; + property shift-held: false; property is-dragging: false; property drag-threshold: 5px; @@ -478,22 +506,26 @@ 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 zoom: 1.0; // Kept for API compatibility, but not used for sizing 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; // Still needed for drag preview coordinates in property node-screen-y: 0px; /// Increment this to force pin position re-reporting (workaround for init timing) in property refresh-trigger: 0; + /// Offset to subtract from position when reporting (for culling margin compensation) + in property report-offset-x: 0px; + in property report-offset-y: 0px; - 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 visual top-left (after subtracting offset) + out property center-x: self.x + self.width / 2 - report-offset-x; + /// Pin center Y relative to node visual top-left (after subtracting offset) + out property center-y: self.y + self.height / 2 - report-offset-y; callback drag-started(int, length, length); callback drag-moved(int, length, length); @@ -544,7 +576,7 @@ export component Pin inherits Rectangle { 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); + 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; diff --git a/node-editor.slint b/node-editor.slint index 72abc5a..4f2aa55 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -30,7 +30,17 @@ // ``` // Import building blocks and re-export for users -import { PinTypes, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } from "node-editor-building-blocks.slint"; +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 } /// Modifier key for triggering box selection over nodes/links @@ -290,8 +300,7 @@ export component NodeEditor { 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.internal-link-start-x, root.internal-link-start-y, x, y); } /// Complete link creation @@ -321,8 +330,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 +350,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 @@ -384,8 +391,7 @@ 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; @@ -394,11 +400,25 @@ export component NodeEditor { } // === 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 => { + 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(); + } /// Call this after initial geometry has been reported to force link path recomputation. public function refresh-links() { @@ -433,6 +453,7 @@ export component NodeEditor { width: 100%; height: 100%; background: root.background-color; + clip: false; // Don't clip scaled content // Grid Path { @@ -480,10 +501,7 @@ 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 (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) { // Start box selection root.internal-is-box-selecting = true; root.box-start-x = self.mouse-x; @@ -514,12 +532,10 @@ export component NodeEditor { // 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 - ); + root.selection-width, root.selection-height); root.selected-link-ids = root.compute-link-box-selection( root.selection-x, root.selection-y, - root.selection-width, root.selection-height - ); + root.selection-width, root.selection-height); root.sync-selection-to-nodes(root.selected-node-ids); root.sync-selection-to-links(root.selected-link-ids); root.selection-changed(); @@ -560,20 +576,31 @@ export component NodeEditor { } } - // === Layer 2: Links and user children (nodes) === + // === Layer 2: World Content (scaled and panned) === + // This container applies zoom via transform-scale and positions content via pan. + 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 (now in world coordinates) + 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; + } - // 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) - now in world coordinates + @children } - // User-provided children (nodes) - @children - // === Layer 3: Overlays === // Selection box diff --git a/src/controller.rs b/src/controller.rs index e06a57e..f93cd95 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -171,22 +171,20 @@ 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 world-space bezier paths from world-space cache data. + /// The parent container applies transform-scale for zoom and positioning for pan, + /// so link paths are computed in world coordinates. 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(); - move |start_pin, end_pin, _version, zoom, pan_x, pan_y| { + move |start_pin, end_pin, _version, _zoom, _pan_x, _pan_y| { + // Compute path in world coordinates - parent container handles zoom/pan let s = state.borrow(); cache .borrow() - .compute_link_path_screen( + .compute_link_path_world( start_pin, end_pin, - zoom, - pan_x, - pan_y, s.bezier_offset, ) .unwrap_or_default() @@ -204,21 +202,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. diff --git a/src/state.rs b/src/state.rs index 64539d1..65ef104 100644 --- a/src/state.rs +++ b/src/state.rs @@ -201,6 +201,33 @@ where Some(generate_bezier_path(sx, sy, ex, ey, zoom, bezier_min_offset)) } + /// Compute bezier path in WORLD coordinates (for use inside a scaled container). + /// + /// Node rects and pin positions are in world coordinates. Output path is also + /// in world coordinates - no zoom/pan transformation applied. The parent + /// container's transform-scale handles the visual scaling. + pub fn compute_link_path_world( + &self, + start_pin: i32, + end_pin: i32, + 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(); + + // World coordinates - no zoom/pan transformation + 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 calculation (scaling handled by container) + 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, From 131a0d31d028a688b648c25f932ab58a1cfe47c8 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 18 Mar 2026 11:12:27 +0000 Subject: [PATCH 02/22] Fix culling error --- examples/minimal/ui/minimal.slint | 36 +++++-------- node-editor-building-blocks.slint | 88 +++++++++++++++---------------- node-editor.slint | 14 ++--- src/controller.rs | 11 ++-- src/state.rs | 30 +++++++---- 5 files changed, 92 insertions(+), 87 deletions(-) diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index 752d4fb..faec527 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -18,20 +18,14 @@ export { LinkData } component SimpleNode inherits BaseNode { in property title: "Node"; - // Set visual size on BaseNode for proper hit-testing/reporting - visual-width: 150px; - visual-height: 100px; + // Set node size for BaseNode + node-width: 150px; + node-height: 100px; - // Total size includes culling margin on all sides - width: self.visual-width + self.culling-margin * 2; - height: self.visual-height + self.culling-margin * 2; - - // Visual content wrapper - offset by culling-margin so it appears at the correct position + // Visual content Rectangle { - x: root.culling-margin; - y: root.culling-margin; - width: root.visual-width; - height: root.visual-height; + width: 100%; + height: 100%; background: root.selected ? #4a6a9a : #333; border-radius: 4px; border-width: 1px; @@ -44,33 +38,29 @@ component SimpleNode inherits BaseNode { } } - // Single input pin - offset by culling-margin + // Single input pin Pin { - x: root.culling-margin; - y: root.culling-margin + root.visual-height / 2 - self.height / 2; + 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; - report-offset-x: root.culling-margin; - report-offset-y: root.culling-margin; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } } - // Single output pin - offset by culling-margin + // Single output pin Pin { - x: root.culling-margin + root.visual-width - self.width; - y: root.culling-margin + root.visual-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; node-screen-x: root.screen-x; node-screen-y: root.screen-y; - report-offset-x: root.culling-margin; - report-offset-y: root.culling-margin; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -124,6 +114,8 @@ export component MainWindow inherits Window { zoom: editor.zoom; pan-x: editor.pan-x; pan-y: editor.pan-y; + viewport-width: editor.width; + viewport-height: editor.height; 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; diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 273a85d..1732d6f 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -314,31 +314,29 @@ export component Link inherits Path { /// Nodes are positioned in WORLD coordinates - the parent NodeEditor applies /// transform-scale for zoom, so no zoom multiplication is needed here. /// -/// Culling: To prevent premature culling near screen edges, set `culling-margin` -/// to extend the element's bounds. The visual content should be placed inside -/// a child element positioned at (culling-margin, culling-margin). +/// Visibility Culling: BaseNode computes whether the node is on-screen and sets +/// `visible: false` for off-screen nodes. Pass viewport-width/viewport-height +/// so visibility can be calculated correctly. 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; // Still passed for coordinate conversions - in property pan-x: 0px; // Still passed for coordinate conversions + 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; - // === Culling Margin === - // Extra margin around node bounds to prevent premature culling when using transform-scale. - // Set to 0px for no margin (old behavior). Visual content should be offset by this amount. - in property culling-margin: 500px; + // === Viewport (for visibility culling) === + in property viewport-width: 10000px; + in property viewport-height: 10000px; - // === Visual Size === - // The size of the actual visual content (for reporting to Rust for hit-testing). - // Children should set these to match their visual content dimensions. - in property visual-width: 150px; - in property visual-height: 100px; + // === 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; @@ -362,52 +360,57 @@ export component BaseNode inherits Rectangle { callback report-rect(int, length, length, length, length); // === Computed Properties === - // World position (for positioning inside scaled container) - property pos-x: world-x + drag-offset-x; - property pos-y: world-y + drag-offset-y; - // Screen position (for coordinate conversions when needed) - out property screen-x: pos-x * zoom + pan-x; - out property screen-y: pos-y * zoom + pan-y; + // World position (for reporting to Rust) + property world-pos-x: world-x + drag-offset-x; + property world-pos-y: world-y + drag-offset-y; + // Screen position + out property screen-x: world-pos-x * zoom + pan-x; + out property screen-y: world-pos-y * zoom + pan-y; + // Position inside scaled container: we want screen = pos * zoom, so pos = screen / zoom + // This ensures nodes appear at correct screen position after transform-scale + property container-pos-x: zoom > 0 ? screen-x / zoom : world-pos-x; + property container-pos-y: zoom > 0 ? screen-y / zoom : world-pos-y; // === Common Styling === - // Position offset by culling-margin so bounds extend earlier for culling detection - x: pos-x - culling-margin; - y: pos-y - culling-margin; + x: container-pos-x; + y: container-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, using visual size for hit-testing) + // Report rect on changes (in WORLD coordinates - without offset) init => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, visual-width, visual-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed node-id => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, visual-width, visual-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed drag-offset-x => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, visual-width, visual-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed drag-offset-y => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, visual-width, visual-height); + report-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, pos-x, pos-y, visual-width, visual-height); + report-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, pos-x, pos-y, visual-width, visual-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } @@ -421,12 +424,12 @@ export component BaseNode inherits Rectangle { } // === Drag Handling === - // TouchArea covers only the visual content area (not the culling margin) + // TouchArea covers the full node area TouchArea { - x: root.culling-margin; - y: root.culling-margin; - width: root.visual-width; - height: root.visual-height; + x: 0; + y: 0; + width: root.node-width; + height: root.node-height; property shift-held: false; property is-dragging: false; @@ -514,18 +517,15 @@ export component Pin inherits Rectangle { in property node-screen-y: 0px; /// Increment this to force pin position re-reporting (workaround for init timing) in property refresh-trigger: 0; - /// Offset to subtract from position when reporting (for culling margin compensation) - in property report-offset-x: 0px; - in property report-offset-y: 0px; // 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 visual top-left (after subtracting offset) - out property center-x: self.x + self.width / 2 - report-offset-x; - /// Pin center Y relative to node visual top-left (after subtracting offset) - out property center-y: self.y + self.height / 2 - report-offset-y; + /// Pin center X relative to node top-left + out property center-x: self.x + self.width / 2; + /// Pin center Y relative to node top-left + out property center-y: self.y + self.height / 2; callback drag-started(int, length, length); callback drag-moved(int, length, length); diff --git a/node-editor.slint b/node-editor.slint index 4f2aa55..c75199e 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -576,11 +576,13 @@ export component NodeEditor { } } - // === Layer 2: World Content (scaled and panned) === - // This container applies zoom via transform-scale and positions content via pan. + // === Layer 2: World Content (scaled) === + // Container at origin with transform-scale. Nodes position themselves such that + // after scaling, they appear at the correct screen position. + // Node position = (world * zoom + pan) / zoom, so after scale: position * zoom = world * zoom + pan ✓ world-content := Rectangle { - x: root.pan-x; - y: root.pan-y; + x: 0px; + y: 0px; width: 100000px; // Large enough for any world content height: 100000px; transform-origin: { x: 0, y: 0 }; @@ -588,7 +590,7 @@ export component NodeEditor { background: transparent; clip: false; - // Render links from `links` property (now in world coordinates) + // Render links from `links` property 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; @@ -597,7 +599,7 @@ export component NodeEditor { hovered: root.hovered-link-id == link.id; } - // User-provided children (nodes) - now in world coordinates + // User-provided children (nodes) @children } diff --git a/src/controller.rs b/src/controller.rs index f93cd95..13c968a 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -171,20 +171,21 @@ impl NodeEditorController { /// Returns a callback for `compute-link-path`. /// - /// Computes world-space bezier paths from world-space cache data. - /// The parent container applies transform-scale for zoom and positioning for pan, - /// so link paths are computed in world coordinates. + /// 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(); - move |start_pin, end_pin, _version, _zoom, _pan_x, _pan_y| { - // Compute path in world coordinates - parent container handles zoom/pan + move |start_pin, end_pin, _version, zoom, pan_x, pan_y| { let s = state.borrow(); cache .borrow() .compute_link_path_world( start_pin, end_pin, + zoom, + pan_x, + pan_y, s.bezier_offset, ) .unwrap_or_default() diff --git a/src/state.rs b/src/state.rs index 65ef104..38abad1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -201,15 +201,18 @@ where Some(generate_bezier_path(sx, sy, ex, ey, zoom, bezier_min_offset)) } - /// Compute bezier path in WORLD coordinates (for use inside a scaled container). + /// Compute bezier path for use inside a scaled container at origin. /// - /// Node rects and pin positions are in world coordinates. Output path is also - /// in world coordinates - no zoom/pan transformation applied. The parent - /// container's transform-scale handles the visual scaling. + /// Container is at (0,0) with transform-scale: zoom. + /// We compute positions such that after scaling, links appear at correct screen position. + /// Position inside container = screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom pub fn compute_link_path_world( &self, start_pin: i32, end_pin: i32, + zoom: f32, + pan_x: f32, + pan_y: f32, bezier_min_offset: f32, ) -> Option { let start_pos = self.pin_positions.get(&start_pin)?; @@ -218,13 +221,20 @@ where let start_rect = self.node_rects.get(&start_pos.node_id)?.rect(); let end_rect = self.node_rects.get(&end_pos.node_id)?.rect(); - // World coordinates - no zoom/pan transformation - 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; + // World coordinates + let world_sx = start_rect.0 + start_pos.rel_x; + let world_sy = start_rect.1 + start_pos.rel_y; + let world_ex = end_rect.0 + end_pos.rel_x; + let world_ey = end_rect.1 + end_pos.rel_y; + + // Container positions: screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom + let safe_zoom = if zoom > 0.001 { zoom } else { 1.0 }; + let sx = world_sx + pan_x / safe_zoom; + let sy = world_sy + pan_y / safe_zoom; + let ex = world_ex + pan_x / safe_zoom; + let ey = world_ey + pan_y / safe_zoom; - // Use zoom=1.0 for bezier offset calculation (scaling handled by container) + // Use zoom=1.0 for bezier offset since scaling is handled by container Some(generate_bezier_path(sx, sy, ex, ey, 1.0, bezier_min_offset)) } From 747f50011c65c57093ca400b0ec06735a625ba46 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 18 Mar 2026 11:38:57 +0000 Subject: [PATCH 03/22] lod example fix --- examples/advanced/ui/filter_node.slint | 135 +++++++++--------- examples/advanced/ui/ui.slint | 72 +++++----- .../animated-links/ui/animated-links.slint | 10 +- examples/custom-shapes/ui/main.slint | 14 +- examples/minimal/ui/minimal.slint | 12 +- .../ui/pin-compatibility.slint | 36 +++-- examples/sugiyama/ui/sugiyama.slint | 10 +- .../zoom-stress-test/ui/control_node.slint | 90 ++++++------ .../zoom-stress-test/ui/display_node.slint | 133 +++++++++-------- examples/zoom-stress-test/ui/input_node.slint | 92 ++++++------ examples/zoom-stress-test/ui/ui.slint | 6 - node-editor-building-blocks.slint | 46 +++--- node-editor.slint | 16 +-- src/state.rs | 31 ++-- tests/ui/test.slint | 33 +++-- 15 files changed, 343 insertions(+), 393 deletions(-) diff --git a/examples/advanced/ui/filter_node.slint b/examples/advanced/ui/filter_node.slint index c63eaa1..5c7c1eb 100644 --- a/examples/advanced/ui/filter_node.slint +++ b/examples/advanced/ui/filter_node.slint @@ -35,8 +35,9 @@ 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; + // No zoom multiplication - transform-scale handles it + width: pulse-state ? 24px : 10px; // 20% bigger when pulsed (20 * 1.2 = 24) + height: pulse-state ? 24px : 10px; background: #FFC107; border-color: #FF9800; @@ -75,10 +76,8 @@ export component FilterNode inherits BaseNode { callback toggle-enabled(int); // node-id callback reset-clicked(int); // node-id - // Pin dimensions (scaled by root.zoom) + // Pin dimensions - no zoom multiplication property base-pin-size: 12px; - property pin-size: base-pin-size * self.zoom; - property pin-radius: pin-size / 2; property pin-margin: 4px; // Layout constants @@ -87,36 +86,43 @@ export component FilterNode inherits BaseNode { property content-padding: 8px; property pin-area-width: 24px; // Space for pins on each side - // Base dimensions - sized to fit content - // Width must accommodate: pin areas (24px×2) + padding (8px×2) + "Type:" label + ComboBox - // Height = VL-padding + title + HL-padding-top + 3*rows + VL-padding + // Base dimensions - sized to fit content (no zoom multiplication) property base-width: 260px; property base-height: content-padding + title-height + content-padding + (row-height * 3) + content-padding; - // 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; - - // Set dimensions - width: base-width * self.zoom; - height: base-height * self.zoom; + // Set node dimensions for BaseNode + node-width: base-width; + node-height: base-height; + + // Pin Y positions - centered on each content row (no zoom multiplication) + 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; + + // Visual background + Rectangle { + width: 100%; + height: 100%; + background: root.selected ? root.background-color-selected : root.background-color; + border-radius: root.border-radius-base; + border-width: root.selected ? root.border-width-selected : root.border-width-normal; + border-color: root.selected ? root.border-color-selected : root.border-color-normal; + } - // Main layout + // Main layout - no zoom multiplication on any dimensions 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 +130,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 +199,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 +246,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,7 +265,7 @@ export component FilterNode inherits BaseNode { // Rows 2-3: empty Rectangle { - height: row-height * root.zoom * 2; + height: row-height * 2; } } } @@ -279,14 +285,13 @@ export component FilterNode inherits BaseNode { // Data Input pin (green, left side, row 1) data-input-pin := Pin { - x: root.pin-margin * root.zoom; - y: root.row-1-center - root.pin-radius; + x: root.pin-margin; + y: root.row-1-center - base-pin-size / 2; 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #4CAF50; hover-color: #66BB6A; base-size: root.base-pin-size; @@ -306,14 +311,13 @@ export component FilterNode inherits BaseNode { // Control Input pin (yellow, left side, row 2) control-input-pin := ControlPin { - x: root.pin-margin * root.zoom; - y: root.row-2-center - root.pin-radius; + x: root.pin-margin; + y: root.row-2-center - base-pin-size / 2; 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #FFC107; hover-color: #FFD54F; base-size: root.base-pin-size; @@ -333,14 +337,13 @@ export component FilterNode inherits BaseNode { // Data Output pin (blue, right side, row 1) data-output-pin := Pin { - x: parent.width - root.pin-margin * root.zoom - root.pin-size; - y: root.row-1-center - root.pin-radius; + x: root.node-width - root.pin-margin - base-pin-size; + y: root.row-1-center - base-pin-size / 2; 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #2196F3; hover-color: #42A5F5; base-size: root.base-pin-size; diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index 4d8cba9..08bb230 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -79,37 +79,39 @@ component Node inherits BaseNode { in property input-pin-id: PinId.make(self.node-id, PinTypes.input); 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-radius: pin-size / 2; + // Base dimensions (from global constants) - no zoom multiplication + node-width: NodeConstants.node-base-width; + node-height: NodeConstants.node-base-height; - // Base dimensions (from global constants) - property base-width: NodeConstants.node-base-width; - property base-height: NodeConstants.node-base-height; + // Expose pin positions (center of pin circles, in world coordinates) + out property input-pin-x: self.pos-x + input-pin.center-x; + out property input-pin-y: self.pos-y + input-pin.center-y; + out property output-pin-x: self.pos-x + output-pin.center-x; + out property output-pin-y: self.pos-y + output-pin.center-y; - // Expose pin positions (center of pin circles, in parent coordinates) - out property input-pin-x: self.screen-x + input-pin.center-x; - out property input-pin-y: self.screen-y + input-pin.center-y; - out property output-pin-x: self.screen-x + output-pin.center-x; - out property output-pin-y: self.screen-y + output-pin.center-y; - - // Set dimensions - width: base-width * self.zoom; - height: base-height * self.zoom; + // Visual background + Rectangle { + width: 100%; + height: 100%; + background: root.selected ? root.background-color-selected : root.background-color; + border-radius: root.border-radius-base; + border-width: root.selected ? root.border-width-selected : root.border-width-normal; + border-color: root.selected ? root.border-color-selected : root.border-color-normal; + } - // Title bar + // Title bar - no zoom multiplication 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; } @@ -125,14 +127,13 @@ component Node inherits BaseNode { // 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #4CAF50; hover-color: #66BB6A; base-size: NodeConstants.pin-size; @@ -152,14 +153,13 @@ component Node inherits BaseNode { // 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: root.node-width - 8px - NodeConstants.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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #2196F3; hover-color: #42A5F5; base-size: NodeConstants.pin-size; @@ -315,8 +315,8 @@ export component MainWindow inherits Window { grid-commands <=> root.grid-commands; links <=> root.links; - // Set pin hit radius from globals - pin-hit-radius: NodeConstants.pin-size * zoom * 0.66; + // Set pin hit radius from globals - no zoom multiplication + pin-hit-radius: NodeConstants.pin-size * 0.66; link-hover-distance: 8px; // Callbacks aliased to root @@ -360,8 +360,6 @@ export component MainWindow inherits Window { 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); } @@ -402,8 +400,6 @@ export component MainWindow inherits Window { 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; filter-type-index: filter-data.filter-type-index; enabled: filter-data.enabled; processed-count: filter-data.processed-count; diff --git a/examples/animated-links/ui/animated-links.slint b/examples/animated-links/ui/animated-links.slint index 35d439e..c877e69 100644 --- a/examples/animated-links/ui/animated-links.slint +++ b/examples/animated-links/ui/animated-links.slint @@ -131,8 +131,8 @@ component SimpleNode inherits BaseNode { 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -151,8 +151,8 @@ component SimpleNode inherits BaseNode { 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -238,8 +238,6 @@ export component MainWindow inherits Window { 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; diff --git a/examples/custom-shapes/ui/main.slint b/examples/custom-shapes/ui/main.slint index 9ecb62a..d396485 100644 --- a/examples/custom-shapes/ui/main.slint +++ b/examples/custom-shapes/ui/main.slint @@ -36,8 +36,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; - node-screen-x: root.screen-x; - node-screen-y: root.screen-y; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -51,8 +51,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; - node-screen-x: root.screen-x; - node-screen-y: root.screen-y; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -66,8 +66,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; - node-screen-x: root.screen-x; - node-screen-y: root.screen-y; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -125,8 +125,6 @@ export component MainWindow inherits Window { 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; diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index faec527..015c435 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -45,8 +45,8 @@ component SimpleNode inherits BaseNode { 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -59,8 +59,8 @@ component SimpleNode inherits BaseNode { 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -112,10 +112,6 @@ export component MainWindow inherits Window { world-x: data.x * 1px; world-y: data.y * 1px; zoom: editor.zoom; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - viewport-width: editor.width; - viewport-height: editor.height; 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; diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index aed910c..c688e35 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -145,7 +145,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -156,7 +156,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -167,7 +167,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -178,7 +178,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -189,7 +189,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -200,7 +200,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -211,7 +211,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -222,7 +222,7 @@ component SourceNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -272,7 +272,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -283,7 +283,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -294,7 +294,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -305,7 +305,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -316,7 +316,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -327,7 +327,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -338,7 +338,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -349,7 +349,7 @@ component SinkNode inherits BaseNode { 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; + node-screen-x: root.pos-x; node-screen-y: root.pos-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); } @@ -435,8 +435,6 @@ export component MainWindow inherits Window { 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; @@ -468,8 +466,6 @@ export component MainWindow inherits Window { 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; diff --git a/examples/sugiyama/ui/sugiyama.slint b/examples/sugiyama/ui/sugiyama.slint index dc960d4..11ee61d 100644 --- a/examples/sugiyama/ui/sugiyama.slint +++ b/examples/sugiyama/ui/sugiyama.slint @@ -36,8 +36,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; - node-screen-x: root.screen-x; - node-screen-y: root.screen-y; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -51,8 +51,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; - node-screen-x: root.screen-x; - node-screen-y: root.screen-y; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -115,8 +115,6 @@ export component MainWindow inherits Window { 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; diff --git a/examples/zoom-stress-test/ui/control_node.slint b/examples/zoom-stress-test/ui/control_node.slint index 8d6efab..321250d 100644 --- a/examples/zoom-stress-test/ui/control_node.slint +++ b/examples/zoom-stress-test/ui/control_node.slint @@ -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,14 +294,13 @@ 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #2196F3; hover-color: #42A5F5; base-size: root.base-pin-size; @@ -318,14 +315,13 @@ 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #FF9800; hover-color: #FFB74D; base-size: root.base-pin-size; diff --git a/examples/zoom-stress-test/ui/display_node.slint b/examples/zoom-stress-test/ui/display_node.slint index e296c62..a312a7c 100644 --- a/examples/zoom-stress-test/ui/display_node.slint +++ b/examples/zoom-stress-test/ui/display_node.slint @@ -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,14 +350,13 @@ 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #9C27B0; hover-color: #BA68C8; base-size: root.base-pin-size; diff --git a/examples/zoom-stress-test/ui/input_node.slint b/examples/zoom-stress-test/ui/input_node.slint index 451f393..4b6669e 100644 --- a/examples/zoom-stress-test/ui/input_node.slint +++ b/examples/zoom-stress-test/ui/input_node.slint @@ -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,16 +240,15 @@ 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; base-color: #4CAF50; hover-color: #66BB6A; base-size: root.base-pin-size; diff --git a/examples/zoom-stress-test/ui/ui.slint b/examples/zoom-stress-test/ui/ui.slint index 794176e..e95f0f5 100644 --- a/examples/zoom-stress-test/ui/ui.slint +++ b/examples/zoom-stress-test/ui/ui.slint @@ -100,8 +100,6 @@ export component MainWindow inherits Window { 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; @@ -145,8 +143,6 @@ export component MainWindow inherits Window { 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; @@ -197,8 +193,6 @@ export component MainWindow inherits Window { 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; diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 1732d6f..04b9004 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -313,25 +313,17 @@ export component Link inherits Path { /// Base component for all node types in the node editor. /// Nodes are positioned in WORLD coordinates - the parent NodeEditor applies /// transform-scale for zoom, so no zoom multiplication is needed here. -/// -/// Visibility Culling: BaseNode computes whether the node is on-screen and sets -/// `visible: false` for off-screen nodes. Pass viewport-width/viewport-height -/// so visibility can be calculated correctly. +/// Nodes position themselves at pure world coordinates. 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; - - // === Viewport (for visibility culling) === - in property viewport-width: 10000px; - in property viewport-height: 10000px; + // Zoom is needed for converting screen-space drag deltas to world-space offsets + in property zoom: 1.0; // === Node Size === // Set these to match your node's visual dimensions. @@ -360,57 +352,51 @@ export component BaseNode inherits Rectangle { callback report-rect(int, length, length, length, length); // === Computed Properties === - // World position (for reporting to Rust) - property world-pos-x: world-x + drag-offset-x; - property world-pos-y: world-y + drag-offset-y; - // Screen position - out property screen-x: world-pos-x * zoom + pan-x; - out property screen-y: world-pos-y * zoom + pan-y; - // Position inside scaled container: we want screen = pos * zoom, so pos = screen / zoom - // This ensures nodes appear at correct screen position after transform-scale - property container-pos-x: zoom > 0 ? screen-x / zoom : world-pos-x; - property container-pos-y: zoom > 0 ? screen-y / zoom : world-pos-y; + // World position (pure world coordinates) + out property pos-x: world-x + drag-offset-x; + out property pos-y: world-y + drag-offset-y; // === Common Styling === - x: container-pos-x; - y: container-pos-y; + // Position at world coordinates - container handles pan/zoom + x: pos-x; + y: 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) + // Report rect on changes (in WORLD coordinates) init => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + report-rect(node-id, pos-x, pos-y, node-width, node-height); } } changed node-id => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + report-rect(node-id, pos-x, pos-y, node-width, node-height); } } changed drag-offset-x => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + report-rect(node-id, pos-x, pos-y, node-width, node-height); } } changed drag-offset-y => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + report-rect(node-id, pos-x, pos-y, node-width, node-height); } } changed node-width => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + report-rect(node-id, pos-x, pos-y, node-width, node-height); } } changed node-height => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + report-rect(node-id, pos-x, pos-y, node-width, node-height); } } diff --git a/node-editor.slint b/node-editor.slint index c75199e..7845476 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -577,12 +577,12 @@ export component NodeEditor { } // === Layer 2: World Content (scaled) === - // Container at origin with transform-scale. Nodes position themselves such that - // after scaling, they appear at the correct screen position. - // Node position = (world * zoom + pan) / zoom, so after scale: position * zoom = world * zoom + pan ✓ + // Container at pan position with transform-scale for zoom. + // Everything inside uses pure world coordinates. + // Math: (world_pos * zoom) + pan = screen_position world-content := Rectangle { - x: 0px; - y: 0px; + x: root.pan-x; + y: root.pan-y; width: 100000px; // Large enough for any world content height: 100000px; transform-origin: { x: 0, y: 0 }; @@ -590,16 +590,16 @@ export component NodeEditor { background: transparent; clip: false; - // Render links from `links` property + // Render links from `links` property (pure world coordinates) 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); + path-commands: root.compute-link-path(link.start-pin-id, link.end-pin-id, root.geometry-version, 1.0, 0.0, 0.0); 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) + // User-provided children (nodes) - pure world coordinates @children } diff --git a/src/state.rs b/src/state.rs index 38abad1..07c8225 100644 --- a/src/state.rs +++ b/src/state.rs @@ -201,18 +201,18 @@ where Some(generate_bezier_path(sx, sy, ex, ey, zoom, bezier_min_offset)) } - /// Compute bezier path for use inside a scaled container at origin. + /// Compute bezier path using pure world coordinates. /// - /// Container is at (0,0) with transform-scale: zoom. - /// We compute positions such that after scaling, links appear at correct screen position. - /// Position inside container = screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom + /// Container is at (pan-x, pan-y) with transform-scale: zoom. + /// Everything inside the container uses pure world coordinates. + /// The zoom and pan parameters are kept for API compatibility but ignored. pub fn compute_link_path_world( &self, start_pin: i32, end_pin: i32, - zoom: f32, - pan_x: f32, - pan_y: f32, + _zoom: f32, + _pan_x: f32, + _pan_y: f32, bezier_min_offset: f32, ) -> Option { let start_pos = self.pin_positions.get(&start_pin)?; @@ -221,18 +221,11 @@ where let start_rect = self.node_rects.get(&start_pos.node_id)?.rect(); let end_rect = self.node_rects.get(&end_pos.node_id)?.rect(); - // World coordinates - let world_sx = start_rect.0 + start_pos.rel_x; - let world_sy = start_rect.1 + start_pos.rel_y; - let world_ex = end_rect.0 + end_pos.rel_x; - let world_ey = end_rect.1 + end_pos.rel_y; - - // Container positions: screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom - let safe_zoom = if zoom > 0.001 { zoom } else { 1.0 }; - let sx = world_sx + pan_x / safe_zoom; - let sy = world_sy + pan_y / safe_zoom; - let ex = world_ex + pan_x / safe_zoom; - let ey = world_ey + pan_y / safe_zoom; + // 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 Some(generate_bezier_path(sx, sy, ex, ey, 1.0, bezier_min_offset)) diff --git a/tests/ui/test.slint b/tests/ui/test.slint index 49aba09..6669711 100644 --- a/tests/ui/test.slint +++ b/tests/ui/test.slint @@ -1,7 +1,13 @@ // 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, +} from "@slint-node-editor/node-editor.slint"; export struct NodeData { id: int, @@ -15,19 +21,22 @@ export { LinkData } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 150px * self.zoom; - height: 100px * self.zoom; + // Node size in world coordinates (no zoom multiplication) + 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; } } @@ -38,9 +47,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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -53,9 +61,8 @@ 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; + node-screen-x: root.pos-x; + node-screen-y: root.pos-y; report-position(id, nid, type, x, y) => { root.report-position(id, nid, type, x, y); } @@ -135,8 +142,6 @@ export component MainWindow inherits Window { 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; From 07f8e496530074e20e7563c082eb2b0cb0e04d9d Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 18 Mar 2026 12:03:33 +0000 Subject: [PATCH 04/22] fixed regression for culling and sugiyama example --- examples/advanced/ui/filter_node.slint | 135 +++++++++--------- examples/advanced/ui/ui.slint | 72 +++++----- .../animated-links/ui/animated-links.slint | 10 +- examples/custom-shapes/ui/main.slint | 14 +- examples/minimal/ui/minimal.slint | 12 +- .../ui/pin-compatibility.slint | 36 ++--- examples/sugiyama/ui/sugiyama.slint | 32 +++-- .../zoom-stress-test/ui/control_node.slint | 8 +- .../zoom-stress-test/ui/display_node.slint | 4 +- examples/zoom-stress-test/ui/input_node.slint | 4 +- examples/zoom-stress-test/ui/ui.slint | 25 ++-- node-editor-building-blocks.slint | 46 +++--- node-editor.slint | 16 +-- src/controller.rs | 12 +- src/state.rs | 31 ++-- tests/ui/test.slint | 33 ++--- 16 files changed, 266 insertions(+), 224 deletions(-) diff --git a/examples/advanced/ui/filter_node.slint b/examples/advanced/ui/filter_node.slint index 5c7c1eb..c63eaa1 100644 --- a/examples/advanced/ui/filter_node.slint +++ b/examples/advanced/ui/filter_node.slint @@ -35,9 +35,8 @@ export struct FilterNodeData { export component ControlPin inherits Pin { in-out property pulse-state: false; border-radius: 4px; - // No zoom multiplication - transform-scale handles it - width: pulse-state ? 24px : 10px; // 20% bigger when pulsed (20 * 1.2 = 24) - height: pulse-state ? 24px : 10px; + 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; background: #FFC107; border-color: #FF9800; @@ -76,8 +75,10 @@ export component FilterNode inherits BaseNode { callback toggle-enabled(int); // node-id callback reset-clicked(int); // node-id - // Pin dimensions - no zoom multiplication + // Pin dimensions (scaled by root.zoom) property base-pin-size: 12px; + property pin-size: base-pin-size * self.zoom; + property pin-radius: pin-size / 2; property pin-margin: 4px; // Layout constants @@ -86,43 +87,36 @@ export component FilterNode inherits BaseNode { property content-padding: 8px; property pin-area-width: 24px; // Space for pins on each side - // Base dimensions - sized to fit content (no zoom multiplication) + // Base dimensions - sized to fit content + // Width must accommodate: pin areas (24px×2) + padding (8px×2) + "Type:" label + ComboBox + // Height = VL-padding + title + HL-padding-top + 3*rows + VL-padding property base-width: 260px; property base-height: content-padding + title-height + content-padding + (row-height * 3) + content-padding; - // Set node dimensions for BaseNode - node-width: base-width; - node-height: base-height; - - // Pin Y positions - centered on each content row (no zoom multiplication) - 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; - - // Visual background - Rectangle { - width: 100%; - height: 100%; - background: root.selected ? root.background-color-selected : root.background-color; - border-radius: root.border-radius-base; - border-width: root.selected ? root.border-width-selected : root.border-width-normal; - border-color: root.selected ? root.border-color-selected : root.border-color-normal; - } + // 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; + + // Set dimensions + width: base-width * self.zoom; + height: base-height * self.zoom; - // Main layout - no zoom multiplication on any dimensions + // Main layout VerticalLayout { - padding: root.content-padding; + padding: root.content-padding * root.zoom; spacing: 0px; // Title bar Rectangle { - height: root.title-height; + height: root.title-height * root.zoom; background: root.selected ? #6a4a9a : #4a3d5d; - border-radius: 4px; + border-radius: 4px * root.zoom; Text { text: root.title; color: white; - font-size: 14px; + font-size: 14px * root.zoom; horizontal-alignment: center; vertical-alignment: center; } @@ -130,60 +124,60 @@ export component FilterNode inherits BaseNode { // Content area with left/right margins for pins HorizontalLayout { - padding-top: root.content-padding; + padding-top: root.content-padding * root.zoom; // Left pin labels column VerticalLayout { - width: pin-area-width; + width: pin-area-width * root.zoom; spacing: 0px; // Row 1: Data input label Rectangle { - height: row-height; + height: row-height * root.zoom; Text { - x: 16px; + x: 16px * root.zoom; text: "In"; color: #888; - font-size: 10px; + font-size: 10px * root.zoom; vertical-alignment: center; } } // Row 2: Control input label Rectangle { - height: row-height; + height: row-height * root.zoom; Text { - x: 16px; + x: 16px * root.zoom; text: "Ctrl"; color: #888; - font-size: 10px; + font-size: 10px * root.zoom; vertical-alignment: center; } } // Row 3: empty Rectangle { - height: row-height; + height: row-height * root.zoom; } } // Center content VerticalLayout { - spacing: 4px; + spacing: 4px * root.zoom; horizontal-stretch: 1; // Row 1: Filter type selector HorizontalLayout { - height: row-height; - spacing: 8px; + height: row-height * root.zoom; + spacing: 8px * root.zoom; alignment: stretch; - padding-top: 4px; - padding-bottom: 4px; + padding-top: 4px * root.zoom; + padding-bottom: 4px * root.zoom; Text { text: "Type:"; color: #aaa; - font-size: 11px; + font-size: 11px * root.zoom; vertical-alignment: center; } @@ -199,34 +193,34 @@ export component FilterNode inherits BaseNode { // Row 2: Status row HorizontalLayout { - height: row-height; - spacing: 12px; + height: row-height * root.zoom; + spacing: 12px * root.zoom; alignment: start; - padding-top: 4px; - padding-bottom: 4px; + padding-top: 4px * root.zoom; + padding-bottom: 4px * root.zoom; Text { text: root.enabled ? "Active" : "Bypassed"; color: root.enabled ? #8f8 : #f88; - font-size: 12px; + font-size: 12px * root.zoom; vertical-alignment: center; } Text { text: "Count: " + root.processed-count; color: #888; - font-size: 11px; + font-size: 11px * root.zoom; vertical-alignment: center; } } // Row 3: Button row HorizontalLayout { - height: row-height; - spacing: 8px; + height: row-height * root.zoom; + spacing: 8px * root.zoom; alignment: start; - padding-top: 2px; - padding-bottom: 2px; + padding-top: 2px * root.zoom; + padding-bottom: 2px * root.zoom; Button { text: root.enabled ? "Bypass" : "Enable"; @@ -246,18 +240,18 @@ export component FilterNode inherits BaseNode { // Right pin labels column VerticalLayout { - width: pin-area-width; + width: pin-area-width * root.zoom; spacing: 0px; // Row 1: Data output label Rectangle { - height: row-height; + height: row-height * root.zoom; Text { x: 0px; - width: parent.width - 16px; + width: parent.width - 16px * root.zoom; text: "Out"; color: #888; - font-size: 10px; + font-size: 10px * root.zoom; vertical-alignment: center; horizontal-alignment: right; } @@ -265,7 +259,7 @@ export component FilterNode inherits BaseNode { // Rows 2-3: empty Rectangle { - height: row-height * 2; + height: row-height * root.zoom * 2; } } } @@ -285,13 +279,14 @@ export component FilterNode inherits BaseNode { // Data Input pin (green, left side, row 1) data-input-pin := Pin { - x: root.pin-margin; - y: root.row-1-center - base-pin-size / 2; + x: root.pin-margin * root.zoom; + 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; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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; @@ -311,13 +306,14 @@ export component FilterNode inherits BaseNode { // Control Input pin (yellow, left side, row 2) control-input-pin := ControlPin { - x: root.pin-margin; - y: root.row-2-center - base-pin-size / 2; + x: root.pin-margin * root.zoom; + 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; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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; @@ -337,13 +333,14 @@ export component FilterNode inherits BaseNode { // Data Output pin (blue, right side, row 1) data-output-pin := Pin { - x: root.node-width - root.pin-margin - base-pin-size; - y: root.row-1-center - base-pin-size / 2; + x: parent.width - root.pin-margin * root.zoom - 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; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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; diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index 08bb230..4d8cba9 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -79,39 +79,37 @@ component Node inherits BaseNode { in property input-pin-id: PinId.make(self.node-id, PinTypes.input); in property output-pin-id: PinId.make(self.node-id, PinTypes.output); - // Base dimensions (from global constants) - no zoom multiplication - node-width: NodeConstants.node-base-width; - node-height: NodeConstants.node-base-height; + // Pin dimensions (scaled by zoom) + property pin-size: NodeConstants.pin-size * self.zoom; + property pin-radius: pin-size / 2; - // Expose pin positions (center of pin circles, in world coordinates) - out property input-pin-x: self.pos-x + input-pin.center-x; - out property input-pin-y: self.pos-y + input-pin.center-y; - out property output-pin-x: self.pos-x + output-pin.center-x; - out property output-pin-y: self.pos-y + output-pin.center-y; + // Base dimensions (from global constants) + property base-width: NodeConstants.node-base-width; + property base-height: NodeConstants.node-base-height; - // Visual background - Rectangle { - width: 100%; - height: 100%; - background: root.selected ? root.background-color-selected : root.background-color; - border-radius: root.border-radius-base; - border-width: root.selected ? root.border-width-selected : root.border-width-normal; - border-color: root.selected ? root.border-color-selected : root.border-color-normal; - } + // Expose pin positions (center of pin circles, in parent coordinates) + out property input-pin-x: self.screen-x + input-pin.center-x; + out property input-pin-y: self.screen-y + input-pin.center-y; + out property output-pin-x: self.screen-x + output-pin.center-x; + out property output-pin-y: self.screen-y + output-pin.center-y; + + // Set dimensions + width: base-width * self.zoom; + height: base-height * self.zoom; - // Title bar - no zoom multiplication + // Title bar Rectangle { - x: 8px; - y: 8px; - width: parent.width - 16px; - height: 24px; + x: 8px * root.zoom; + y: 8px * root.zoom; + width: parent.width - 16px * root.zoom; + height: 24px * root.zoom; background: root.selected ? #4a6a9a : #3d3d3d; - border-radius: 4px; + border-radius: 4px * root.zoom; Text { text: root.title; color: white; - font-size: 14px; + font-size: 14px * root.zoom; horizontal-alignment: center; vertical-alignment: center; } @@ -127,13 +125,14 @@ component Node inherits BaseNode { // Input pin (green) - with drag-to-link support input-pin := Pin { - x: 8px; - y: 8px + 24px + 8px; // below title bar + x: 8px * root.zoom; + y: (8px + 24px + 8px) * root.zoom; // below title bar pin-id: root.input-pin-id; node-id: root.node-id; pin-type: PinTypes.input; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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; @@ -153,13 +152,14 @@ component Node inherits BaseNode { // Output pin (blue) - with drag-to-link support output-pin := Pin { - x: root.node-width - 8px - NodeConstants.pin-size; - y: 8px + 24px + 8px; // below title bar + x: parent.width - 8px * root.zoom - root.pin-size; + y: (8px + 24px + 8px) * root.zoom; // below title bar pin-id: root.output-pin-id; node-id: root.node-id; pin-type: PinTypes.output; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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; @@ -315,8 +315,8 @@ export component MainWindow inherits Window { grid-commands <=> root.grid-commands; links <=> root.links; - // Set pin hit radius from globals - no zoom multiplication - pin-hit-radius: NodeConstants.pin-size * 0.66; + // Set pin hit radius from globals + pin-hit-radius: NodeConstants.pin-size * zoom * 0.66; link-hover-distance: 8px; // Callbacks aliased to root @@ -360,6 +360,8 @@ export component MainWindow inherits Window { 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); } @@ -400,6 +402,8 @@ export component MainWindow inherits Window { 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; filter-type-index: filter-data.filter-type-index; enabled: filter-data.enabled; processed-count: filter-data.processed-count; diff --git a/examples/animated-links/ui/animated-links.slint b/examples/animated-links/ui/animated-links.slint index c877e69..35d439e 100644 --- a/examples/animated-links/ui/animated-links.slint +++ b/examples/animated-links/ui/animated-links.slint @@ -131,8 +131,8 @@ component SimpleNode inherits BaseNode { zoom: root.zoom; base-color: accent-color.darker(30%); hover-color: accent-color; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -151,8 +151,8 @@ component SimpleNode inherits BaseNode { zoom: root.zoom; base-color: accent-color.darker(30%); hover-color: accent-color; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -238,6 +238,8 @@ export component MainWindow inherits Window { 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; diff --git a/examples/custom-shapes/ui/main.slint b/examples/custom-shapes/ui/main.slint index d396485..9ecb62a 100644 --- a/examples/custom-shapes/ui/main.slint +++ b/examples/custom-shapes/ui/main.slint @@ -36,8 +36,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -51,8 +51,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -66,8 +66,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -125,6 +125,8 @@ export component MainWindow inherits Window { 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; diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index 015c435..faec527 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -45,8 +45,8 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2; node-id: root.node-id; pin-type: PinTypes.input; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -59,8 +59,8 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2 + 1; node-id: root.node-id; pin-type: PinTypes.output; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -112,6 +112,10 @@ export component MainWindow inherits Window { world-x: data.x * 1px; world-y: data.y * 1px; zoom: editor.zoom; + pan-x: editor.pan-x; + pan-y: editor.pan-y; + viewport-width: editor.width; + viewport-height: editor.height; 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; diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index c688e35..aed910c 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -145,7 +145,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -156,7 +156,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -167,7 +167,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -178,7 +178,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -189,7 +189,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -200,7 +200,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -211,7 +211,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -222,7 +222,7 @@ component SourceNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -272,7 +272,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -283,7 +283,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -294,7 +294,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -305,7 +305,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -316,7 +316,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -327,7 +327,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -338,7 +338,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -349,7 +349,7 @@ component SinkNode inherits BaseNode { 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.pos-x; node-screen-y: root.pos-y; + 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); } @@ -435,6 +435,8 @@ export component MainWindow inherits Window { 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; @@ -466,6 +468,8 @@ export component MainWindow inherits Window { 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; diff --git a/examples/sugiyama/ui/sugiyama.slint b/examples/sugiyama/ui/sugiyama.slint index 11ee61d..36c2571 100644 --- a/examples/sugiyama/ui/sugiyama.slint +++ b/examples/sugiyama/ui/sugiyama.slint @@ -1,4 +1,10 @@ -import { NodeEditor, BaseNode, Pin, PinTypes, LinkData } from "@slint-node-editor/node-editor.slint"; +import { + NodeEditor, + BaseNode, + Pin, + PinTypes, + LinkData, +} from "@slint-node-editor/node-editor.slint"; import { Button } from "std-widgets.slint"; export struct NodeData { @@ -7,24 +13,25 @@ export struct NodeData { x: float, y: float, } - export { LinkData } 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; } } @@ -36,8 +43,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.input; zoom: root.zoom; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -51,8 +58,8 @@ component SimpleNode inherits BaseNode { node-id: root.node-id; pin-type: PinTypes.output; zoom: root.zoom; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -96,6 +103,7 @@ export component MainWindow inherits Window { root.layout-requested(); } } + Button { text: "Scramble"; clicked => { @@ -115,6 +123,8 @@ export component MainWindow inherits Window { 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; diff --git a/examples/zoom-stress-test/ui/control_node.slint b/examples/zoom-stress-test/ui/control_node.slint index 321250d..8a5a161 100644 --- a/examples/zoom-stress-test/ui/control_node.slint +++ b/examples/zoom-stress-test/ui/control_node.slint @@ -299,8 +299,8 @@ export component ControlNode inherits BaseNode { pin-id: root.input-pin-id; node-id: root.node-id; pin-type: PinTypes.input; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; base-color: #2196F3; hover-color: #42A5F5; base-size: root.base-pin-size; @@ -320,8 +320,8 @@ export component ControlNode inherits BaseNode { pin-id: root.output-pin-id; node-id: root.node-id; pin-type: PinTypes.output; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; base-color: #FF9800; hover-color: #FFB74D; base-size: root.base-pin-size; diff --git a/examples/zoom-stress-test/ui/display_node.slint b/examples/zoom-stress-test/ui/display_node.slint index a312a7c..23ffe29 100644 --- a/examples/zoom-stress-test/ui/display_node.slint +++ b/examples/zoom-stress-test/ui/display_node.slint @@ -355,8 +355,8 @@ export component DisplayNode inherits BaseNode { pin-id: root.input-pin-id; node-id: root.node-id; pin-type: PinTypes.input; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; base-color: #9C27B0; hover-color: #BA68C8; base-size: root.base-pin-size; diff --git a/examples/zoom-stress-test/ui/input_node.slint b/examples/zoom-stress-test/ui/input_node.slint index 4b6669e..13d458e 100644 --- a/examples/zoom-stress-test/ui/input_node.slint +++ b/examples/zoom-stress-test/ui/input_node.slint @@ -247,8 +247,8 @@ export component InputNode inherits BaseNode { pin-id: root.output-pin-id; node-id: root.node-id; pin-type: PinTypes.output; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + node-screen-x: root.screen-x; + node-screen-y: root.screen-y; base-color: #4CAF50; hover-color: #66BB6A; base-size: root.base-pin-size; diff --git a/examples/zoom-stress-test/ui/ui.slint b/examples/zoom-stress-test/ui/ui.slint index e95f0f5..251f936 100644 --- a/examples/zoom-stress-test/ui/ui.slint +++ b/examples/zoom-stress-test/ui/ui.slint @@ -7,7 +7,11 @@ // - 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, +} 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"; @@ -71,8 +75,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 { @@ -99,6 +102,8 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; + pan-x: editor.pan-x; + pan-y: editor.pan-y; zoom: editor.zoom; text-value: data.text-value; spin-value: data.spin-value; @@ -142,6 +147,8 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; + pan-x: editor.pan-x; + pan-y: editor.pan-y; zoom: editor.zoom; check-a: data.check-a; check-b: data.check-b; @@ -192,6 +199,8 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; + pan-x: editor.pan-x; + pan-y: editor.pan-y; zoom: editor.zoom; progress: data.progress; status-text: data.status-text; @@ -245,14 +254,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-building-blocks.slint b/node-editor-building-blocks.slint index 04b9004..1732d6f 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -313,17 +313,25 @@ export component Link inherits Path { /// Base component for all node types in the node editor. /// Nodes are positioned in WORLD coordinates - the parent NodeEditor applies /// transform-scale for zoom, so no zoom multiplication is needed here. -/// Nodes position themselves at pure world coordinates. +/// +/// Visibility Culling: BaseNode computes whether the node is on-screen and sets +/// `visible: false` for off-screen nodes. Pass viewport-width/viewport-height +/// so visibility can be calculated correctly. 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; - // Zoom is needed for converting screen-space drag deltas to world-space offsets - in property zoom: 1.0; + + // === Viewport (for visibility culling) === + in property viewport-width: 10000px; + in property viewport-height: 10000px; // === Node Size === // Set these to match your node's visual dimensions. @@ -352,51 +360,57 @@ export component BaseNode inherits Rectangle { callback report-rect(int, length, length, length, length); // === Computed Properties === - // World position (pure world coordinates) - out property pos-x: world-x + drag-offset-x; - out property pos-y: world-y + drag-offset-y; + // World position (for reporting to Rust) + property world-pos-x: world-x + drag-offset-x; + property world-pos-y: world-y + drag-offset-y; + // Screen position + out property screen-x: world-pos-x * zoom + pan-x; + out property screen-y: world-pos-y * zoom + pan-y; + // Position inside scaled container: we want screen = pos * zoom, so pos = screen / zoom + // This ensures nodes appear at correct screen position after transform-scale + property container-pos-x: zoom > 0 ? screen-x / zoom : world-pos-x; + property container-pos-y: zoom > 0 ? screen-y / zoom : world-pos-y; // === Common Styling === - // Position at world coordinates - container handles pan/zoom - x: pos-x; - y: pos-y; + x: container-pos-x; + y: container-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) + // Report rect on changes (in WORLD coordinates - without offset) init => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, node-width, node-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed node-id => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, node-width, node-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed drag-offset-x => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, node-width, node-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed drag-offset-y => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, node-width, node-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed node-width => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, node-width, node-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed node-height => { if (node-id > 0) { - report-rect(node-id, pos-x, pos-y, node-width, node-height); + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } diff --git a/node-editor.slint b/node-editor.slint index 7845476..c75199e 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -577,12 +577,12 @@ export component NodeEditor { } // === Layer 2: World Content (scaled) === - // Container at pan position with transform-scale for zoom. - // Everything inside uses pure world coordinates. - // Math: (world_pos * zoom) + pan = screen_position + // Container at origin with transform-scale. Nodes position themselves such that + // after scaling, they appear at the correct screen position. + // Node position = (world * zoom + pan) / zoom, so after scale: position * zoom = world * zoom + pan ✓ world-content := Rectangle { - x: root.pan-x; - y: root.pan-y; + x: 0px; + y: 0px; width: 100000px; // Large enough for any world content height: 100000px; transform-origin: { x: 0, y: 0 }; @@ -590,16 +590,16 @@ export component NodeEditor { background: transparent; clip: false; - // Render links from `links` property (pure world coordinates) + // Render links from `links` property for link in root.links: Link { - path-commands: root.compute-link-path(link.start-pin-id, link.end-pin-id, root.geometry-version, 1.0, 0.0, 0.0); + 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) - pure world coordinates + // User-provided children (nodes) @children } diff --git a/src/controller.rs b/src/controller.rs index 13c968a..f6964ec 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -553,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(); @@ -570,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/state.rs b/src/state.rs index 07c8225..38abad1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -201,18 +201,18 @@ where Some(generate_bezier_path(sx, sy, ex, ey, zoom, bezier_min_offset)) } - /// Compute bezier path using pure world coordinates. + /// Compute bezier path for use inside a scaled container at origin. /// - /// Container is at (pan-x, pan-y) with transform-scale: zoom. - /// Everything inside the container uses pure world coordinates. - /// The zoom and pan parameters are kept for API compatibility but ignored. + /// Container is at (0,0) with transform-scale: zoom. + /// We compute positions such that after scaling, links appear at correct screen position. + /// Position inside container = screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom pub fn compute_link_path_world( &self, start_pin: i32, end_pin: i32, - _zoom: f32, - _pan_x: f32, - _pan_y: f32, + zoom: f32, + pan_x: f32, + pan_y: f32, bezier_min_offset: f32, ) -> Option { let start_pos = self.pin_positions.get(&start_pin)?; @@ -221,11 +221,18 @@ where 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; + // World coordinates + let world_sx = start_rect.0 + start_pos.rel_x; + let world_sy = start_rect.1 + start_pos.rel_y; + let world_ex = end_rect.0 + end_pos.rel_x; + let world_ey = end_rect.1 + end_pos.rel_y; + + // Container positions: screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom + let safe_zoom = if zoom > 0.001 { zoom } else { 1.0 }; + let sx = world_sx + pan_x / safe_zoom; + let sy = world_sy + pan_y / safe_zoom; + let ex = world_ex + pan_x / safe_zoom; + let ey = world_ey + pan_y / safe_zoom; // Use zoom=1.0 for bezier offset since scaling is handled by container Some(generate_bezier_path(sx, sy, ex, ey, 1.0, bezier_min_offset)) diff --git a/tests/ui/test.slint b/tests/ui/test.slint index 6669711..49aba09 100644 --- a/tests/ui/test.slint +++ b/tests/ui/test.slint @@ -1,13 +1,7 @@ // 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 } from "@slint-node-editor/node-editor.slint"; export struct NodeData { id: int, @@ -21,22 +15,19 @@ export { LinkData } component SimpleNode inherits BaseNode { in property title: "Node"; - // Node size in world coordinates (no zoom multiplication) - node-width: 150px; - node-height: 100px; + width: 150px * self.zoom; + height: 100px * self.zoom; Rectangle { - width: 100%; - height: 100%; background: root.selected ? #4a6a9a : #333; - border-radius: 4px; - border-width: 1px; + border-radius: 4px * root.zoom; + border-width: 1px * root.zoom; border-color: #666; Text { text: root.title; color: white; - font-size: 14px; + font-size: 14px * root.zoom; } } @@ -47,8 +38,9 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2; node-id: root.node-id; pin-type: PinTypes.input; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -61,8 +53,9 @@ component SimpleNode inherits BaseNode { pin-id: root.node-id * 2 + 1; node-id: root.node-id; pin-type: PinTypes.output; - node-screen-x: root.pos-x; - node-screen-y: root.pos-y; + 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); } @@ -142,6 +135,8 @@ export component MainWindow inherits Window { 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; From b595df3c416c0d22d6cb54948e0ba6f65f32b286 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 18 Mar 2026 12:35:33 +0000 Subject: [PATCH 05/22] Added change callbacks for world X and Y --- examples/advanced/ui/filter_node.slint | 88 +++++++++---------- examples/advanced/ui/ui.slint | 26 +++--- .../animated-links/ui/animated-links.slint | 18 ++-- examples/custom-shapes/ui/main.slint | 10 +-- .../ui/pin-compatibility.slint | 88 +++++++++---------- node-editor-building-blocks.slint | 30 ++++++- node-editor.slint | 14 ++- 7 files changed, 154 insertions(+), 120 deletions(-) diff --git a/examples/advanced/ui/filter_node.slint b/examples/advanced/ui/filter_node.slint index c63eaa1..7a5ac10 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,7 +259,7 @@ export component FilterNode inherits BaseNode { // Rows 2-3: empty Rectangle { - height: row-height * root.zoom * 2; + height: row-height * 2; } } } @@ -279,7 +279,7 @@ export component FilterNode inherits BaseNode { // 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; @@ -306,7 +306,7 @@ export component FilterNode inherits BaseNode { // 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; @@ -333,7 +333,7 @@ export component FilterNode inherits BaseNode { // 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; diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index 4d8cba9..410a8cf 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -80,7 +80,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,22 +94,22 @@ 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; } @@ -125,8 +125,8 @@ component Node inherits BaseNode { // 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; @@ -152,8 +152,8 @@ component Node inherits BaseNode { // 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; diff --git a/examples/animated-links/ui/animated-links.slint b/examples/animated-links/ui/animated-links.slint index 35d439e..161207f 100644 --- a/examples/animated-links/ui/animated-links.slint +++ b/examples/animated-links/ui/animated-links.slint @@ -85,13 +85,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 +99,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 +116,7 @@ component SimpleNode inherits BaseNode { Text { text: root.title; color: white; - font-size: 14px * root.zoom; + font-size: 14px ; font-weight: 600; } } diff --git a/examples/custom-shapes/ui/main.slint b/examples/custom-shapes/ui/main.slint index 9ecb62a..dfc6847 100644 --- a/examples/custom-shapes/ui/main.slint +++ b/examples/custom-shapes/ui/main.slint @@ -12,19 +12,19 @@ export { LinkData } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 150px * self.zoom; - height: 100px * self.zoom; + width: 150px ; + height: 100px ; Rectangle { 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 ; } } diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index aed910c..e99704e 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -84,20 +84,20 @@ component ValidatedPin inherits Rectangle { 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,41 +109,41 @@ 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; + width: L.node-width ; + height: L.node-height ; callback report-position(int, int, int, length, length); 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; + 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; 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; @@ -154,7 +154,7 @@ component SourceNode inherits BaseNode { } ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 1 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -165,7 +165,7 @@ component SourceNode inherits BaseNode { } ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 2 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -176,7 +176,7 @@ component SourceNode inherits BaseNode { } ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 3 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -187,7 +187,7 @@ component SourceNode inherits BaseNode { } ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 4 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -198,7 +198,7 @@ component SourceNode inherits BaseNode { } ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 5 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -209,7 +209,7 @@ component SourceNode inherits BaseNode { } ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 6 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -220,7 +220,7 @@ component SourceNode inherits BaseNode { } ValidatedPin { x: parent.width - self.width; - y: (L.first-pin-y + 7 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -236,41 +236,41 @@ 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; + width: L.node-width ; + height: L.node-height ; callback report-position(int, int, int, length, length); 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; + 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; 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; @@ -281,7 +281,7 @@ component SinkNode inherits BaseNode { } ValidatedPin { x: 0px; - y: (L.first-pin-y + 1 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -292,7 +292,7 @@ component SinkNode inherits BaseNode { } ValidatedPin { x: 0px; - y: (L.first-pin-y + 2 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -303,7 +303,7 @@ component SinkNode inherits BaseNode { } ValidatedPin { x: 0px; - y: (L.first-pin-y + 3 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -314,7 +314,7 @@ component SinkNode inherits BaseNode { } ValidatedPin { x: 0px; - y: (L.first-pin-y + 4 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -325,7 +325,7 @@ component SinkNode inherits BaseNode { } ValidatedPin { x: 0px; - y: (L.first-pin-y + 5 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -336,7 +336,7 @@ component SinkNode inherits BaseNode { } ValidatedPin { x: 0px; - y: (L.first-pin-y + 6 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; @@ -347,7 +347,7 @@ component SinkNode inherits BaseNode { } ValidatedPin { x: 0px; - y: (L.first-pin-y + 7 * L.pin-spacing + 6px) * root.zoom - self.height/2; + 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; 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; diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 1732d6f..ea964ab 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -310,19 +310,35 @@ export component Link inherits Path { commands: path-commands; } +/// Container for node content inside NodeEditor. +/// Exposes zoom/pan/viewport properties that BaseNode can reference via parent. +/// This allows nodes to automatically inherit transform state without explicit binding. +export component WorldContent inherits Rectangle { + in property zoom: 1.0; + in property pan-x: 0px; + in property pan-y: 0px; + in property viewport-width: 10000px; + in property viewport-height: 10000px; + background: transparent; + @children +} + /// Base component for all node types in the node editor. /// Nodes are positioned in WORLD coordinates - the parent NodeEditor applies /// transform-scale for zoom, so no zoom multiplication is needed here. /// +/// When placed inside a NodeEditor, zoom/pan values are automatically inherited +/// from the parent WorldContent component - no explicit binding needed. +/// /// Visibility Culling: BaseNode computes whether the node is on-screen and sets -/// `visible: false` for off-screen nodes. Pass viewport-width/viewport-height -/// so visibility can be calculated correctly. +/// `visible: false` for off-screen nodes. export component BaseNode inherits Rectangle { // === Common Properties === in property node-id; in property selected: false; in property world-x; in property world-y; + // These are automatically provided when placed inside NodeEditor's WorldContent in property zoom: 1.0; in property pan-x: 0px; in property pan-y: 0px; @@ -403,6 +419,16 @@ export component BaseNode inherits Rectangle { report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } + changed world-x => { + if (node-id > 0) { + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + } + } + changed world-y => { + if (node-id > 0) { + report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + } + } changed node-width => { if (node-id > 0) { report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); diff --git a/node-editor.slint b/node-editor.slint index c75199e..452b53b 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -40,8 +40,9 @@ import { Link, BaseNode, Pin, + WorldContent, } from "node-editor-building-blocks.slint"; -export { PinTypes, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } +export { PinTypes, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin, WorldContent } /// Modifier key for triggering box selection over nodes/links export enum BoxSelectionModifier { @@ -580,15 +581,22 @@ export component NodeEditor { // Container at origin with transform-scale. Nodes position themselves such that // after scaling, they appear at the correct screen position. // Node position = (world * zoom + pan) / zoom, so after scale: position * zoom = world * zoom + pan ✓ - world-content := Rectangle { + // WorldContent exposes zoom/pan/viewport so child nodes auto-inherit these values. + world-content := WorldContent { x: 0px; y: 0px; width: 100000px; // Large enough for any world content height: 100000px; transform-origin: { x: 0, y: 0 }; transform-scale: root.zoom; - background: transparent; clip: false; + + // Expose transform state to child nodes + zoom: root.zoom; + pan-x: root.pan-x; + pan-y: root.pan-y; + viewport-width: root.width; + viewport-height: root.height; // Render links from `links` property for link in root.links: Link { From cef6e38b583b5f2a5a1212919244fca92e4ab9c4 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Thu, 19 Mar 2026 12:50:19 +0000 Subject: [PATCH 06/22] attempting to remove pan and zoom callbacks from examples library working minimal working --- REFACTOR_PLAN.md | 220 +++++++++++++++++ examples/minimal/src/main.rs | 67 +++-- examples/minimal/ui/minimal.slint | 65 +---- .../zoom-stress-test/ui/control_node.slint | 6 +- .../zoom-stress-test/ui/display_node.slint | 6 +- examples/zoom-stress-test/ui/input_node.slint | 6 +- examples/zoom-stress-test/ui/ui.slint | 58 ----- node-editor-app.slint | 42 ++++ node-editor-building-blocks.slint | 230 +++++++++++------- node-editor.slint | 90 ++++--- src/callbacks.rs | 53 ++++ src/state.rs | 39 ++- tests/common/harness.rs | 7 +- tests/ui/test.slint | 49 ++-- 14 files changed, 620 insertions(+), 318 deletions(-) create mode 100644 REFACTOR_PLAN.md create mode 100644 node-editor-app.slint create mode 100644 src/callbacks.rs 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/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index 5689dac..e8b9b7b 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -1,6 +1,7 @@ use slint::{Color, Model, ModelRc, SharedString, VecModel}; use slint_node_editor::NodeEditorController; use std::rc::Rc; +use std::cell::RefCell; slint::include_modules!(); @@ -8,6 +9,9 @@ fn main() { let window = MainWindow::new().unwrap(); let ctrl = NodeEditorController::new(); let w = window.as_weak(); + + // Track dragged node ID locally + let dragged_node_id = Rc::new(RefCell::new(0i32)); // Set up nodes (keep reference for drag updates) let nodes = Rc::new(VecModel::from(vec![ @@ -27,52 +31,43 @@ fn main() { }, ])))); - // 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({ + // Wire GeometryCallbacks to controller + window.global::().on_report_node_rect({ let ctrl = ctrl.clone(); move |id, x, y, width, h| { ctrl.handle_node_rect(id, x, y, width, h); } }); - window.on_pin_position_changed({ + window.global::().on_report_pin_position({ 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.global::().on_start_node_drag({ + let dragged = dragged_node_id.clone(); + move |node_id, _already_selected, _world_x, _world_y| { + *dragged.borrow_mut() = node_id; } }); - window.on_update_viewport({ - let ctrl = ctrl.clone(); + window.global::().on_update_node_drag({ let w = w.clone(); - move |z, pan_x, pan_y| { + move |offset_x, offset_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)); + let drag_state = w.global::(); + drag_state.set_drag_offset_x(offset_x); + drag_state.set_drag_offset_y(offset_y); } } }); - // Node drag - update positions in model - window.on_node_drag_ended({ - let ctrl = ctrl.clone(); + window.global::().on_end_node_drag({ + let dragged = dragged_node_id.clone(); move |delta_x, delta_y| { - let node_id = ctrl.dragged_node_id(); + let node_id = *dragged.borrow(); for i in 0..nodes.row_count() { if let Some(mut node) = nodes.row_data(i) { if node.id == node_id { @@ -86,6 +81,26 @@ fn main() { } }); - window.invoke_request_grid_update(); + // Wire global computational callbacks + let computations = window.global::(); + + // Link path computation + computations.on_compute_link_path(ctrl.compute_link_path_callback()); + + // Viewport updates + computations.on_viewport_changed({ + let ctrl = ctrl.clone(); + let w = w.clone(); + move |z, pan_x, pan_y| { + 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)); + } + } + }); + + // Initial grid generation + window.set_grid_commands(ctrl.generate_initial_grid(window.get_width_(), window.get_height_())); + window.run().unwrap(); } diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index faec527..8be8287 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -3,6 +3,9 @@ import { BaseNode, Pin, PinTypes, + DragState, + GeometryCallbacks, + NodeEditorComputations, LinkData, } from "@slint-node-editor/node-editor.slint"; @@ -13,8 +16,8 @@ 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"; @@ -22,7 +25,7 @@ component SimpleNode inherits BaseNode { node-width: 150px; node-height: 100px; - // Visual content + // Visual content - Add any bespoke UI here! Rectangle { width: 100%; height: 100%; @@ -38,7 +41,7 @@ component SimpleNode inherits BaseNode { } } - // Single input pin + // Pins - no callback forwarding needed! Pin { x: 0px; y: root.node-height / 2 - self.height / 2; @@ -47,12 +50,8 @@ component SimpleNode inherits BaseNode { pin-type: PinTypes.input; 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: root.node-width - self.width; y: root.node-height / 2 - self.height / 2; @@ -61,13 +60,7 @@ component SimpleNode inherits BaseNode { pin-type: PinTypes.output; 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 { @@ -78,64 +71,24 @@ 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; viewport-width: editor.width; viewport-height: editor.height; - 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); - } } } } diff --git a/examples/zoom-stress-test/ui/control_node.slint b/examples/zoom-stress-test/ui/control_node.slint index 8a5a161..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 === diff --git a/examples/zoom-stress-test/ui/display_node.slint b/examples/zoom-stress-test/ui/display_node.slint index 23ffe29..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 === diff --git a/examples/zoom-stress-test/ui/input_node.slint b/examples/zoom-stress-test/ui/input_node.slint index 13d458e..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 === diff --git a/examples/zoom-stress-test/ui/ui.slint b/examples/zoom-stress-test/ui/ui.slint index 251f936..4aaf2e3 100644 --- a/examples/zoom-stress-test/ui/ui.slint +++ b/examples/zoom-stress-test/ui/ui.slint @@ -102,9 +102,6 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - zoom: editor.zoom; text-value: data.text-value; spin-value: data.spin-value; combo-index: data.combo-index; @@ -114,22 +111,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); } @@ -147,9 +128,6 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - zoom: editor.zoom; check-a: data.check-a; check-b: data.check-b; switch-value: data.switch-value; @@ -160,22 +138,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); } @@ -199,9 +161,6 @@ export component MainWindow inherits Window { title: data.title; world-x: data.world-x * 1px; world-y: data.world-y * 1px; - pan-x: editor.pan-x; - pan-y: editor.pan-y; - zoom: editor.zoom; progress: data.progress; status-text: data.status-text; color-r: data.color-r; @@ -213,23 +172,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(); - } } } 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 ea964ab..a9d8dcf 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -17,6 +17,98 @@ 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; +} + +/// 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; +} + +/// 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; +} /// Optional layout helpers for common node structures. /// @@ -310,42 +402,21 @@ export component Link inherits Path { commands: path-commands; } -/// Container for node content inside NodeEditor. -/// Exposes zoom/pan/viewport properties that BaseNode can reference via parent. -/// This allows nodes to automatically inherit transform state without explicit binding. -export component WorldContent inherits Rectangle { - in property zoom: 1.0; - in property pan-x: 0px; - in property pan-y: 0px; - in property viewport-width: 10000px; - in property viewport-height: 10000px; - background: transparent; - @children -} - /// Base component for all node types in the node editor. -/// Nodes are positioned in WORLD coordinates - the parent NodeEditor applies -/// transform-scale for zoom, so no zoom multiplication is needed here. -/// -/// When placed inside a NodeEditor, zoom/pan values are automatically inherited -/// from the parent WorldContent component - no explicit binding needed. +/// 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. /// -/// Visibility Culling: BaseNode computes whether the node is on-screen and sets -/// `visible: false` for off-screen nodes. +/// 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; - // These are automatically provided when placed inside NodeEditor's WorldContent - 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; - - // === Viewport (for visibility culling) === + 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; @@ -377,19 +448,16 @@ export component BaseNode inherits Rectangle { // === Computed Properties === // World position (for reporting to Rust) - property world-pos-x: world-x + drag-offset-x; - property world-pos-y: world-y + drag-offset-y; - // Screen position - out property screen-x: world-pos-x * zoom + pan-x; - out property screen-y: world-pos-y * zoom + pan-y; - // Position inside scaled container: we want screen = pos * zoom, so pos = screen / zoom - // This ensures nodes appear at correct screen position after transform-scale - property container-pos-x: zoom > 0 ? screen-x / zoom : world-pos-x; - property container-pos-y: zoom > 0 ? screen-y / zoom : world-pos-y; + property world-pos-x: world-x + persistent-offset-x + drag-area.current-drag-offset-x; + property world-pos-y: world-y + persistent-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: container-pos-x; - y: container-pos-y; + // 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 @@ -399,44 +467,42 @@ export component BaseNode inherits Rectangle { // Report rect on changes (in WORLD coordinates - without offset) init => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-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, world-pos-x, world-pos-y, node-width, node-height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } - changed drag-offset-x => { - if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); - } - } - changed drag-offset-y => { - if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); - } - } 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, world-pos-x, world-pos-y, node-width, node-height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } 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, world-pos-x, world-pos-y, node-width, node-height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed node-width => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } changed node-height => { if (node-id > 0) { - report-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } @@ -451,7 +517,7 @@ export component BaseNode inherits Rectangle { // === Drag Handling === // TouchArea covers the full node area - TouchArea { + drag-area := TouchArea { x: 0; y: 0; width: root.node-width; @@ -484,24 +550,29 @@ 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); + GeometryCallbacks.click-node(node-id, shift-held); } // 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; } else { // Click without drag - if we deferred selection, do it now if was-selected-on-press && !shift-held { - root.clicked(node-id, shift-held); + GeometryCallbacks.click-node(node-id, shift-held); } // 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; @@ -517,13 +588,16 @@ export component BaseNode inherits Rectangle { 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 { is-dragging = true; - root.drag-started(node-id, selected, world-x, world-y); + GeometryCallbacks.start-node-drag(node-id, selected, world-x, world-y); } } 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); + current-drag-offset-x = ((self.absolute-position.x + self.mouse-x) - start-abs-x) / ViewportState.zoom; + current-drag-offset-y = ((self.absolute-position.y + self.mouse-y) - start-abs-y) / ViewportState.zoom; + GeometryCallbacks.update-node-drag(current-drag-offset-x, current-drag-offset-y); + // Update geometry for live link updates + GeometryVersion.version += 1; + GeometryCallbacks.report-node-rect(node-id, world-x + current-drag-offset-x, world-y + current-drag-offset-y, node-width, node-height); } } } @@ -535,11 +609,10 @@ 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; // Kept for API compatibility, but not used for sizing in property base-color: #888888; in property hover-color: #aaaaaa; in property base-size: 12px; - in property node-screen-x: 0px; // Still needed for drag preview coordinates + in property node-screen-x: 0px; // Needed for drag preview coordinates in property node-screen-y: 0px; /// Increment this to force pin position re-reporting (workaround for init timing) in property refresh-trigger: 0; @@ -565,32 +638,27 @@ export component Pin inherits Rectangle { init => { 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 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); } } @@ -602,18 +670,16 @@ export component Pin inherits Rectangle { 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); + GeometryCallbacks.start-link-from-pin(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); + GeometryCallbacks.complete-link(); } } moved => { if drag-active { - root.drag-moved(pin-id, + GeometryCallbacks.update-link-preview( node-screen-x + root.x + self.mouse-x, node-screen-y + root.y + self.mouse-y); } diff --git a/node-editor.slint b/node-editor.slint index 452b53b..7be1359 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -32,6 +32,11 @@ // Import building blocks and re-export for users import { PinTypes, + ViewportState, + DragState, + GeometryVersion, + GeometryCallbacks, + NodeEditorComputations, NodeStyleDefaults, MinimapNode, MinimapPosition, @@ -40,9 +45,8 @@ import { Link, BaseNode, Pin, - WorldContent, } from "node-editor-building-blocks.slint"; -export { PinTypes, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin, WorldContent } +export { PinTypes, ViewportState, DragState, GeometryVersion, GeometryCallbacks, NodeEditorComputations, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } /// Modifier key for triggering box selection over nodes/links export enum BoxSelectionModifier { @@ -70,6 +74,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; @@ -124,6 +135,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; @@ -180,6 +192,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; @@ -219,19 +232,11 @@ export component NodeEditor { /// Compute link preview path callback compute-link-preview-path(/* start-x */ length, /* start-y */ length, /* end-x */ length, /* end-y */ length) -> string; - /// 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; - // === 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(); @@ -247,11 +252,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. @@ -267,13 +267,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); } @@ -371,6 +371,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; @@ -396,23 +397,41 @@ export component NodeEditor { // 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 => { + ViewportState.pan-x = root.pan-x; request-grid-update(); - viewport-changed(); + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } changed pan-y => { + ViewportState.pan-y = root.pan-y; request-grid-update(); - viewport-changed(); + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } changed zoom => { - root.geometry-version += 1; + ViewportState.zoom = root.zoom; + GeometryVersion.version += 1; request-grid-update(); - viewport-changed(); + 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 => { request-grid-update(); @@ -423,9 +442,9 @@ export component NodeEditor { /// 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 === @@ -578,32 +597,25 @@ export component NodeEditor { } // === Layer 2: World Content (scaled) === - // Container at origin with transform-scale. Nodes position themselves such that - // after scaling, they appear at the correct screen position. - // Node position = (world * zoom + pan) / zoom, so after scale: position * zoom = world * zoom + pan ✓ - // WorldContent exposes zoom/pan/viewport so child nodes auto-inherit these values. - world-content := WorldContent { - x: 0px; - y: 0px; + // 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; - - // Expose transform state to child nodes - zoom: root.zoom; - pan-x: root.pan-x; - pan-y: root.pan-y; - viewport-width: root.width; - viewport-height: root.height; // Render links from `links` property 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); + 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: root.is-link-selected(link.id, root.selection-version); + selected: NodeEditorComputations.is-link-selected(link.id, root.selection-version); hovered: root.hovered-link-id == link.id; } 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/state.rs b/src/state.rs index 38abad1..f0900bf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -201,18 +201,24 @@ where Some(generate_bezier_path(sx, sy, ex, ey, zoom, bezier_min_offset)) } - /// Compute bezier path for use inside a scaled container at origin. + /// Compute bezier path in pure world coordinates. /// - /// Container is at (0,0) with transform-scale: zoom. - /// We compute positions such that after scaling, links appear at correct screen position. - /// Position inside container = screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom + /// 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, - pan_x: f32, - pan_y: f32, + _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)?; @@ -221,20 +227,13 @@ where let start_rect = self.node_rects.get(&start_pos.node_id)?.rect(); let end_rect = self.node_rects.get(&end_pos.node_id)?.rect(); - // World coordinates - let world_sx = start_rect.0 + start_pos.rel_x; - let world_sy = start_rect.1 + start_pos.rel_y; - let world_ex = end_rect.0 + end_pos.rel_x; - let world_ey = end_rect.1 + end_pos.rel_y; - - // Container positions: screen / zoom = (world * zoom + pan) / zoom = world + pan/zoom - let safe_zoom = if zoom > 0.001 { zoom } else { 1.0 }; - let sx = world_sx + pan_x / safe_zoom; - let sy = world_sy + pan_y / safe_zoom; - let ex = world_ex + pan_x / safe_zoom; - let ey = world_ey + pan_y / safe_zoom; + // 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 + // 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)) } diff --git a/tests/common/harness.rs b/tests/common/harness.rs index 8f4def6..d6db5a7 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(); @@ -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(); diff --git a/tests/ui/test.slint b/tests/ui/test.slint index 49aba09..095fae9 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,35 +19,36 @@ 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) => { @@ -48,12 +58,11 @@ component SimpleNode inherits BaseNode { // 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) => { @@ -87,10 +96,8 @@ export component MainWindow inherits Window { 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; @@ -106,8 +113,6 @@ export component MainWindow inherits Window { callback clear-selection <=> editor.clear-selection; callback sync-selection-to-nodes <=> editor.sync-selection-to-nodes; 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 callback compute-pin-at <=> editor.compute-pin-at; @@ -125,20 +130,12 @@ 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; + // drag-offset-x and drag-offset-y computed automatically in BaseNode report-rect(id, x, y, w, h) => { editor.report-node-rect(id, x, y, w, h); From 3e542ec1cdaac6e71b4604f5c629fe5444eaa79b Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Thu, 19 Mar 2026 13:05:01 +0000 Subject: [PATCH 07/22] reduce boilerplate in rust --- examples/minimal/src/main.rs | 72 +++-------- examples/sugiyama/src/main.rs | 86 +++++++------ examples/sugiyama/ui/sugiyama.slint | 50 +------- src/lib.rs | 2 + src/setup.rs | 189 ++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 142 deletions(-) create mode 100644 src/setup.rs diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index e8b9b7b..873df3e 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -1,17 +1,15 @@ use slint::{Color, Model, ModelRc, SharedString, VecModel}; -use slint_node_editor::NodeEditorController; +use slint_node_editor::NodeEditorSetup; use std::rc::Rc; -use std::cell::RefCell; slint::include_modules!(); fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); let w = window.as_weak(); - // Track dragged node ID locally - let dragged_node_id = Rc::new(RefCell::new(0i32)); + // Create setup helper - manages controller and state + let setup = NodeEditorSetup::new(); // Set up nodes (keep reference for drag updates) let nodes = Rc::new(VecModel::from(vec![ @@ -31,43 +29,15 @@ fn main() { }, ])))); - // Wire GeometryCallbacks to controller - window.global::().on_report_node_rect({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.global::().on_report_pin_position({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); + // Wire geometry callbacks using helper (3 lines instead of 20+) + window.global::().on_report_node_rect(setup.on_report_node_rect()); + window.global::().on_report_pin_position(setup.on_report_pin_position()); + window.global::().on_start_node_drag(setup.on_start_node_drag()); - window.global::().on_start_node_drag({ - let dragged = dragged_node_id.clone(); - move |node_id, _already_selected, _world_x, _world_y| { - *dragged.borrow_mut() = node_id; - } - }); - - window.global::().on_update_node_drag({ - let w = w.clone(); - move |offset_x, offset_y| { - if let Some(w) = w.upgrade() { - let drag_state = w.global::(); - drag_state.set_drag_offset_x(offset_x); - drag_state.set_drag_offset_y(offset_y); - } - } - }); - - window.global::().on_end_node_drag({ - let dragged = dragged_node_id.clone(); - move |delta_x, delta_y| { - let node_id = *dragged.borrow(); + // Wire drag end with model update + window.global::().on_end_node_drag(setup.on_end_node_drag({ + 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 { @@ -79,28 +49,24 @@ fn main() { } } } - }); + })); - // Wire global computational callbacks - let computations = window.global::(); + // Wire computational callbacks (1 line each) + window.global::().on_compute_link_path(setup.on_compute_link_path()); - // Link path computation - computations.on_compute_link_path(ctrl.compute_link_path_callback()); - - // Viewport updates - computations.on_viewport_changed({ - let ctrl = ctrl.clone(); + window.global::().on_viewport_changed({ + let ctrl = setup.controller().clone(); let w = w.clone(); - move |z, pan_x, pan_y| { + move |zoom, pan_x, pan_y| { if let Some(w) = w.upgrade() { - ctrl.set_viewport(z, pan_x, pan_y); + ctrl.set_viewport(zoom, pan_x, pan_y); w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); } } }); // Initial grid generation - window.set_grid_commands(ctrl.generate_initial_grid(window.get_width_(), window.get_height_())); + window.set_grid_commands(setup.controller().generate_initial_grid(window.get_width_(), window.get_height_())); window.run().unwrap(); } diff --git a/examples/sugiyama/src/main.rs b/examples/sugiyama/src/main.rs index 8b9d9fc..c326662 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, Direction, NodeEditorSetup, SugiyamaConfig}; slint::include_modules!(); @@ -27,7 +27,7 @@ fn build_node_index(nodes: &VecModel) -> HashMap { fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); + let setup = NodeEditorSetup::new(); let w = window.as_weak(); // Create a DAG with 8 nodes: @@ -79,6 +79,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 +104,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 +125,24 @@ 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(); + // Wire geometry callbacks using helper (4 lines instead of 20+) + window.global::().on_report_node_rect(setup.on_report_node_rect()); + window.global::().on_report_pin_position(setup.on_report_pin_position()); + window.global::().on_start_node_drag(setup.on_start_node_drag()); + + // Wire drag end with model update + window.global::().on_end_node_drag(setup.on_end_node_drag({ 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 { @@ -175,8 +154,27 @@ fn main() { } } } + })); + + // Wire computational callbacks + window.global::().on_compute_link_path(setup.on_compute_link_path()); + + window.global::().on_viewport_changed({ + let ctrl = setup.controller().clone(); + let w = w.clone(); + move |zoom, pan_x, pan_y| { + if let Some(w) = w.upgrade() { + ctrl.set_viewport(zoom, pan_x, pan_y); + w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); + } + } }); - window.invoke_request_grid_update(); + // Generate initial grid + window.set_grid_commands(setup.controller().generate_initial_grid( + window.get_width_(), + window.get_height_(), + )); + window.run().unwrap(); } diff --git a/examples/sugiyama/ui/sugiyama.slint b/examples/sugiyama/ui/sugiyama.slint index 36c2571..51e515f 100644 --- a/examples/sugiyama/ui/sugiyama.slint +++ b/examples/sugiyama/ui/sugiyama.slint @@ -4,6 +4,9 @@ import { Pin, PinTypes, LinkData, + GeometryCallbacks, + NodeEditorComputations, + GeometryVersion, } from "@slint-node-editor/node-editor.slint"; import { Button } from "std-widgets.slint"; @@ -13,7 +16,7 @@ export struct NodeData { x: float, y: float, } -export { LinkData } +export { LinkData, GeometryCallbacks, NodeEditorComputations, GeometryVersion } component SimpleNode inherits BaseNode { in property title: "Node"; @@ -42,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 @@ -57,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 { @@ -76,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(); @@ -113,38 +98,13 @@ 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); - } } } } diff --git a/src/lib.rs b/src/lib.rs index 003d5a4..175633e 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,6 @@ 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}; diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..0e45037 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,189 @@ +//! Simplified setup helpers for NodeEditor with globals architecture. +//! +//! The [`NodeEditorSetup`] eliminates boilerplate by providing pre-configured closures +//! that you can directly wire to the globals. This reduces the typical 40+ lines of +//! callback wiring to just a few lines. +//! +//! # Example +//! +//! ```ignore +//! use slint_node_editor::NodeEditorSetup; +//! +//! slint::include_modules!(); +//! +//! fn main() { +//! let window = MainWindow::new().unwrap(); +//! let nodes = Rc::new(VecModel::from(vec![/* your nodes */])); +//! +//! // Create setup helper +//! let setup = NodeEditorSetup::new(); +//! +//! // Wire geometry callbacks +//! window.global::().on_report_node_rect(setup.on_report_node_rect()); +//! window.global::().on_report_pin_position(setup.on_report_pin_position()); +//! window.global::().on_start_node_drag(setup.on_start_node_drag()); +//! window.global::().on_end_node_drag(setup.on_end_node_drag(|node_id, dx, dy| { +//! // Update your model +//! })); +//! +//! // Wire computations +//! window.global::().on_compute_link_path(setup.on_compute_link_path()); +//! +//! window.run().unwrap(); +//! } +//! ``` + +use crate::controller::NodeEditorController; +use std::cell::RefCell; +use std::rc::Rc; + +/// Setup helper that bundles NodeEditorController and common state. +/// +/// This helper reduces boilerplate by: +/// - Managing the controller lifecycle +/// - Tracking dragged node ID +/// - Providing pre-configured closures for all callbacks +pub struct NodeEditorSetup { + controller: Rc, + dragged_node_id: Rc>, +} + +impl Default for NodeEditorSetup { + fn default() -> Self { + Self::new() + } +} + +impl NodeEditorSetup { + /// Create a new setup helper with a fresh controller and state. + pub fn new() -> Self { + Self { + controller: Rc::new(NodeEditorController::new()), + dragged_node_id: Rc::new(RefCell::new(0i32)), + } + } + + /// Get access to the underlying controller for advanced operations. + pub fn controller(&self) -> &NodeEditorController { + &self.controller + } + + /// Get the ID of the currently dragged node (0 if none). + pub fn dragged_node_id(&self) -> i32 { + *self.dragged_node_id.borrow() + } + + /// Create a closure for `GeometryCallbacks.on_report_node_rect`. + /// + /// Wire this directly: + /// ```ignore + /// window.global::() + /// .on_report_node_rect(setup.on_report_node_rect()); + /// ``` + pub fn on_report_node_rect(&self) -> impl Fn(i32, f32, f32, f32, f32) + 'static { + let ctrl = self.controller.clone(); + move |id, x, y, width, h| { + ctrl.handle_node_rect(id, x, y, width, h); + } + } + + /// Create a closure for `GeometryCallbacks.on_report_pin_position`. + /// + /// Wire this directly: + /// ```ignore + /// window.global::() + /// .on_report_pin_position(setup.on_report_pin_position()); + /// ``` + pub fn on_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); + } + } + + /// Create a closure for `GeometryCallbacks.on_start_node_drag`. + /// + /// Wire this directly: + /// ```ignore + /// window.global::() + /// .on_start_node_drag(setup.on_start_node_drag()); + /// ``` + pub fn on_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; + } + } + + /// Create a closure for `GeometryCallbacks.on_end_node_drag`. + /// + /// This takes a user callback that receives `(node_id, delta_x, delta_y)` + /// for updating the node model. + /// + /// # Example + /// ```ignore + /// window.global::().on_end_node_drag( + /// setup.on_end_node_drag({ + /// let nodes = nodes.clone(); + /// move |node_id, dx, dy| { + /// // Update your model here + /// } + /// }) + /// ); + /// ``` + pub fn on_end_node_drag(&self, update_model: F) -> impl Fn(f32, f32) + 'static + where + F: Fn(i32, f32, f32) + 'static, + { + let dragged = self.dragged_node_id.clone(); + move |delta_x, delta_y| { + let node_id = *dragged.borrow(); + update_model(node_id, delta_x, delta_y); + } + } + + /// Create a closure for `NodeEditorComputations.on_compute_link_path`. + /// + /// Wire this directly: + /// ```ignore + /// window.global::() + /// .on_compute_link_path(setup.on_compute_link_path()); + /// ``` + pub fn on_compute_link_path(&self) -> impl Fn(i32, i32, i32, f32, f32, f32) -> slint::SharedString + 'static { + self.controller.compute_link_path_callback() + } + + /// Create a closure for `NodeEditorComputations.on_viewport_changed`. + /// + /// This handles viewport updates and grid regeneration. + /// + /// # Example + /// ```ignore + /// window.global::().on_viewport_changed( + /// setup.on_viewport_changed(&window.as_weak(), |w| { + /// (w.get_width_(), w.get_height_()) + /// }) + /// ); + /// ``` + pub fn on_viewport_changed( + &self, + window: &slint::Weak, + get_dimensions: F, + ) -> impl Fn(f32, f32, f32) + 'static + where + W: slint::ComponentHandle + 'static, + F: Fn(&W) -> (f32, f32) + 'static, + { + let ctrl = self.controller.clone(); + let w = window.clone(); + move |zoom, pan_x, pan_y| { + ctrl.set_viewport(zoom, pan_x, pan_y); + if let Some(window) = w.upgrade() { + let (_width, _height) = get_dimensions(&window); + let _grid = ctrl.generate_grid(_width, _height, pan_x, pan_y); + // Note: Can't set grid_commands here without knowing the window type + // Caller must do: w.set_grid_commands(grid); + } + } + } +} From 11ea906c24720ed07331332bc9a34967a72298d4 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Thu, 19 Mar 2026 14:01:04 +0000 Subject: [PATCH 08/22] reduce callbacks in rust --- examples/minimal/src/main.rs | 29 +++-- examples/sugiyama/src/main.rs | 23 ++-- src/setup.rs | 196 +++++++++++++--------------------- 3 files changed, 101 insertions(+), 147 deletions(-) diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index 873df3e..f13fc6c 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -7,11 +7,8 @@ slint::include_modules!(); fn main() { let window = MainWindow::new().unwrap(); let w = window.as_weak(); - - // Create setup helper - manages controller and state - let setup = NodeEditorSetup::new(); - // 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 }, @@ -28,14 +25,9 @@ fn main() { line_width: 2.0, }, ])))); - - // Wire geometry callbacks using helper (3 lines instead of 20+) - window.global::().on_report_node_rect(setup.on_report_node_rect()); - window.global::().on_report_pin_position(setup.on_report_pin_position()); - window.global::().on_start_node_drag(setup.on_start_node_drag()); - // Wire drag end with model update - window.global::().on_end_node_drag(setup.on_end_node_drag({ + // Create setup with model update logic - this is the ONLY callback you provide + let setup = NodeEditorSetup::new({ let nodes = nodes.clone(); move |node_id, delta_x, delta_y| { for i in 0..nodes.row_count() { @@ -49,10 +41,17 @@ fn main() { } } } - })); + }); - // Wire computational callbacks (1 line each) - window.global::().on_compute_link_path(setup.on_compute_link_path()); + // Wire geometry callbacks + 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()); + + // Wire computational callbacks + window.global::().on_compute_link_path(setup.compute_link_path()); window.global::().on_viewport_changed({ let ctrl = setup.controller().clone(); @@ -66,7 +65,7 @@ fn main() { }); // Initial grid generation - window.set_grid_commands(setup.controller().generate_initial_grid(window.get_width_(), window.get_height_())); + window.set_grid_commands(setup.generate_initial_grid(window.get_width_(), window.get_height_())); window.run().unwrap(); } diff --git a/examples/sugiyama/src/main.rs b/examples/sugiyama/src/main.rs index c326662..3fd98ad 100644 --- a/examples/sugiyama/src/main.rs +++ b/examples/sugiyama/src/main.rs @@ -27,7 +27,6 @@ fn build_node_index(nodes: &VecModel) -> HashMap { fn main() { let window = MainWindow::new().unwrap(); - let setup = NodeEditorSetup::new(); let w = window.as_weak(); // Create a DAG with 8 nodes: @@ -134,13 +133,8 @@ fn main() { } }); - // Wire geometry callbacks using helper (4 lines instead of 20+) - window.global::().on_report_node_rect(setup.on_report_node_rect()); - window.global::().on_report_pin_position(setup.on_report_pin_position()); - window.global::().on_start_node_drag(setup.on_start_node_drag()); - - // Wire drag end with model update - window.global::().on_end_node_drag(setup.on_end_node_drag({ + // 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() { @@ -154,10 +148,17 @@ fn main() { } } } - })); + }); + + // Wire geometry callbacks + 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()); // Wire computational callbacks - window.global::().on_compute_link_path(setup.on_compute_link_path()); + window.global::().on_compute_link_path(setup.compute_link_path()); window.global::().on_viewport_changed({ let ctrl = setup.controller().clone(); @@ -171,7 +172,7 @@ fn main() { }); // Generate initial grid - window.set_grid_commands(setup.controller().generate_initial_grid( + window.set_grid_commands(setup.generate_initial_grid( window.get_width_(), window.get_height_(), )); diff --git a/src/setup.rs b/src/setup.rs index 0e45037..af81f74 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,13 +1,14 @@ //! Simplified setup helpers for NodeEditor with globals architecture. //! -//! The [`NodeEditorSetup`] eliminates boilerplate by providing pre-configured closures -//! that you can directly wire to the globals. This reduces the typical 40+ lines of -//! callback wiring to just a few lines. +//! 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!(); //! @@ -15,19 +16,30 @@ //! let window = MainWindow::new().unwrap(); //! let nodes = Rc::new(VecModel::from(vec![/* your nodes */])); //! -//! // Create setup helper -//! let setup = NodeEditorSetup::new(); +//! // 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 geometry callbacks -//! window.global::().on_report_node_rect(setup.on_report_node_rect()); -//! window.global::().on_report_pin_position(setup.on_report_pin_position()); -//! window.global::().on_start_node_drag(setup.on_start_node_drag()); -//! window.global::().on_end_node_drag(setup.on_end_node_drag(|node_id, dx, dy| { -//! // Update your model -//! })); -//! -//! // Wire computations -//! window.global::().on_compute_link_path(setup.on_compute_link_path()); +//! // 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(); //! } @@ -37,29 +49,34 @@ use crate::controller::NodeEditorController; use std::cell::RefCell; use std::rc::Rc; -/// Setup helper that bundles NodeEditorController and common state. +/// Setup helper that bundles NodeEditorController and automatic model updates. /// -/// This helper reduces boilerplate by: +/// This helper eliminates boilerplate by: /// - Managing the controller lifecycle -/// - Tracking dragged node ID -/// - Providing pre-configured closures for all callbacks -pub struct NodeEditorSetup { +/// - Tracking dragged node ID internally +/// - 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>, + on_node_moved: Rc, } -impl Default for NodeEditorSetup { - fn default() -> Self { - Self::new() - } -} - -impl NodeEditorSetup { - /// Create a new setup helper with a fresh controller and state. - pub fn new() -> Self { +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)), + on_node_moved: Rc::new(on_node_moved), } } @@ -68,122 +85,59 @@ impl NodeEditorSetup { &self.controller } - /// Get the ID of the currently dragged node (0 if none). - pub fn dragged_node_id(&self) -> i32 { - *self.dragged_node_id.borrow() - } - - /// Create a closure for `GeometryCallbacks.on_report_node_rect`. - /// - /// Wire this directly: - /// ```ignore - /// window.global::() - /// .on_report_node_rect(setup.on_report_node_rect()); - /// ``` - pub fn on_report_node_rect(&self) -> impl Fn(i32, f32, f32, f32, f32) + 'static { + /// 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, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); + move |id, x, y, w, h| { + ctrl.handle_node_rect(id, x, y, w, h); } } - /// Create a closure for `GeometryCallbacks.on_report_pin_position`. - /// - /// Wire this directly: - /// ```ignore - /// window.global::() - /// .on_report_pin_position(setup.on_report_pin_position()); - /// ``` - pub fn on_report_pin_position(&self) -> impl Fn(i32, i32, i32, f32, f32) + 'static { + /// 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); } } - /// Create a closure for `GeometryCallbacks.on_start_node_drag`. - /// - /// Wire this directly: - /// ```ignore - /// window.global::() - /// .on_start_node_drag(setup.on_start_node_drag()); - /// ``` - pub fn on_start_node_drag(&self) -> impl Fn(i32, bool, f32, f32) + 'static { + /// 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; } } - /// Create a closure for `GeometryCallbacks.on_end_node_drag`. - /// - /// This takes a user callback that receives `(node_id, delta_x, delta_y)` - /// for updating the node model. - /// - /// # Example - /// ```ignore - /// window.global::().on_end_node_drag( - /// setup.on_end_node_drag({ - /// let nodes = nodes.clone(); - /// move |node_id, dx, dy| { - /// // Update your model here - /// } - /// }) - /// ); - /// ``` - pub fn on_end_node_drag(&self, update_model: F) -> impl Fn(f32, f32) + 'static - where - F: Fn(i32, f32, f32) + 'static, - { + /// Callback for `GeometryCallbacks.on_end_node_drag`. + /// + /// This automatically calls your model-update closure with the dragged node ID. + pub fn end_node_drag(&self) -> impl Fn(f32, f32) + 'static { let dragged = self.dragged_node_id.clone(); + let on_moved = self.on_node_moved.clone(); move |delta_x, delta_y| { let node_id = *dragged.borrow(); - update_model(node_id, delta_x, delta_y); + on_moved(node_id, delta_x, delta_y); } } - /// Create a closure for `NodeEditorComputations.on_compute_link_path`. - /// - /// Wire this directly: - /// ```ignore - /// window.global::() - /// .on_compute_link_path(setup.on_compute_link_path()); - /// ``` - pub fn on_compute_link_path(&self) -> impl Fn(i32, i32, i32, f32, f32, f32) -> slint::SharedString + 'static { + /// 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() } - /// Create a closure for `NodeEditorComputations.on_viewport_changed`. - /// - /// This handles viewport updates and grid regeneration. - /// - /// # Example - /// ```ignore - /// window.global::().on_viewport_changed( - /// setup.on_viewport_changed(&window.as_weak(), |w| { - /// (w.get_width_(), w.get_height_()) - /// }) - /// ); - /// ``` - pub fn on_viewport_changed( - &self, - window: &slint::Weak, - get_dimensions: F, - ) -> impl Fn(f32, f32, f32) + 'static - where - W: slint::ComponentHandle + 'static, - F: Fn(&W) -> (f32, f32) + 'static, - { - let ctrl = self.controller.clone(); - let w = window.clone(); - move |zoom, pan_x, pan_y| { - ctrl.set_viewport(zoom, pan_x, pan_y); - if let Some(window) = w.upgrade() { - let (_width, _height) = get_dimensions(&window); - let _grid = ctrl.generate_grid(_width, _height, pan_x, pan_y); - // Note: Can't set grid_commands here without knowing the window type - // Caller must do: w.set_grid_commands(grid); - } - } + /// 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); } } From 4ecf1793dc8a65e3c0a85f6c1345ff1099667952 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Thu, 19 Mar 2026 14:44:12 +0000 Subject: [PATCH 09/22] macro to replace all rust callbacks --- Cargo.lock | 56 ++------------------------ Cargo.toml | 2 +- examples/minimal/src/main.rs | 16 +++----- examples/sugiyama/src/main.rs | 75 +++++++++++++++++------------------ src/lib.rs | 28 +++++++++++++ 5 files changed, 73 insertions(+), 104 deletions(-) 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/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index f13fc6c..e5b1e7a 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -1,5 +1,5 @@ use slint::{Color, Model, ModelRc, SharedString, VecModel}; -use slint_node_editor::NodeEditorSetup; +use slint_node_editor::{NodeEditorSetup, wire_node_editor}; use std::rc::Rc; slint::include_modules!(); @@ -26,7 +26,7 @@ fn main() { }, ])))); - // Create setup with model update logic - this is the ONLY callback you provide + // Create setup with model update logic let setup = NodeEditorSetup::new({ let nodes = nodes.clone(); move |node_id, delta_x, delta_y| { @@ -43,16 +43,10 @@ fn main() { } }); - // Wire geometry callbacks - 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()); - - // Wire computational callbacks - window.global::().on_compute_link_path(setup.compute_link_path()); + // Wire all callbacks with one macro call + wire_node_editor!(window, setup); + // Viewport change handling (for grid updates) window.global::().on_viewport_changed({ let ctrl = setup.controller().clone(); let w = w.clone(); diff --git a/examples/sugiyama/src/main.rs b/examples/sugiyama/src/main.rs index 3fd98ad..f7f0d8d 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, NodeEditorSetup, SugiyamaConfig}; +use slint_node_editor::{sugiyama_layout, wire_node_editor, Direction, NodeEditorSetup, SugiyamaConfig}; slint::include_modules!(); @@ -29,37 +29,41 @@ fn main() { let window = MainWindow::new().unwrap(); 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() @@ -150,15 +154,8 @@ fn main() { } }); - // Wire geometry callbacks - 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()); - - // Wire computational callbacks - window.global::().on_compute_link_path(setup.compute_link_path()); + // Wire all callbacks with one macro call + wire_node_editor!(window, setup); window.global::().on_viewport_changed({ let ctrl = setup.controller().clone(); diff --git a/src/lib.rs b/src/lib.rs index 175633e..4c062ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,3 +81,31 @@ 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 eliminates the boilerplate of wiring geometry and computation callbacks. +/// It expands in your crate where the generated Slint types are available. +/// +/// # 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); +/// ``` +#[macro_export] +macro_rules! wire_node_editor { + ($window:expr, $setup:expr) => {{ + 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()); + }}; +} From 9cdec06a4f09734302eef937042d8746372d1828 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 10:00:48 +0000 Subject: [PATCH 10/22] updated all demos and started migration notes --- examples/advanced/src/main.rs | 17 +-- examples/advanced/ui/filter_node.slint | 57 +------- examples/advanced/ui/ui.slint | 127 +++-------------- examples/animated-links/src/main.rs | 28 +++- .../animated-links/ui/animated-links.slint | 52 ++----- examples/custom-shapes/src/main.rs | 36 +++-- examples/custom-shapes/ui/main.slint | 85 +++-------- examples/custom-shapes/ui/main.slint.new | 134 ++++++++++++++++++ examples/pin-compatibility/src/main.rs | 14 +- .../ui/pin-compatibility.slint | 95 ++++--------- examples/zoom-stress-test/src/main.rs | 15 +- examples/zoom-stress-test/ui/ui.slint | 13 +- node-editor-building-blocks.slint | 48 +++++-- node-editor.slint | 19 ++- tests/common/harness.rs | 9 +- tests/ui/test.slint | 34 +---- 16 files changed, 333 insertions(+), 450 deletions(-) create mode 100644 examples/custom-shapes/ui/main.slint.new diff --git a/examples/advanced/src/main.rs b/examples/advanced/src/main.rs index bf09bc2..93d6e66 100644 --- a/examples/advanced/src/main.rs +++ b/examples/advanced/src/main.rs @@ -278,14 +278,14 @@ fn main() { } }); - window.on_pin_position_changed({ + window.global::().on_report_pin_position({ 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({ + window.global::().on_report_node_rect({ let ctrl = ctrl.clone(); move |id, x, y, width, height| { ctrl.handle_node_rect(id, x, y, width, height); @@ -332,7 +332,7 @@ fn main() { } }); - window.on_compute_link_path({ + window.global::().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| { @@ -350,13 +350,13 @@ fn main() { 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 === @@ -479,12 +479,13 @@ fn main() { } }); - window.on_update_viewport({ + // Viewport change handling (grid updates) - now via NodeEditorComputations global + window.global::().on_viewport_changed({ 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); + ctrl.set_viewport(zoom, pan_x, pan_y); // Update grid w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); diff --git a/examples/advanced/ui/filter_node.slint b/examples/advanced/ui/filter_node.slint index 7a5ac10..d79655e 100644 --- a/examples/advanced/ui/filter_node.slint +++ b/examples/advanced/ui/filter_node.slint @@ -265,96 +265,47 @@ export component FilterNode inherits BaseNode { } } - // 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 ; + 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 ; + 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.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 410a8cf..eb157ad 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 { @@ -116,65 +118,34 @@ component Node inherits BaseNode { } // 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 ; - y: (8px + 24px + 8px) ; // 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.pin-size; - y: (8px + 24px + 8px) ; // 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; @@ -216,11 +187,7 @@ export component MainWindow inherits Window { 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, 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 { @@ -254,9 +221,7 @@ export component MainWindow inherits Window { callback sync-selection-to-nodes <=> editor.sync-selection-to-nodes; 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; @@ -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..e74b1d3 100644 --- a/examples/animated-links/src/main.rs +++ b/examples/animated-links/src/main.rs @@ -64,8 +64,22 @@ fn main() { Color::from_argb_u8(255, 52, 211, 153), // Green ]; - // Core callbacks - window.on_compute_link_path(ctrl.compute_link_path_callback()); + // Core callbacks via globals + window.global::().on_compute_link_path({ + let ctrl = ctrl.clone(); + let w = window.as_weak(); + move |start_pin, end_pin, _version, _zoom, _pan_x, _pan_y| { + 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() + } + }); window.on_node_drag_started(ctrl.node_drag_started_callback()); // Pin hit testing @@ -160,15 +174,15 @@ fn main() { } }); - // Geometry tracking - window.on_node_rect_changed({ + // Geometry tracking - use GeometryCallbacks global + window.global::().on_report_node_rect({ let ctrl = ctrl.clone(); move |id, x, y, width, h| { ctrl.handle_node_rect(id, x, y, width, h); } }); - window.on_pin_position_changed({ + window.global::().on_report_pin_position({ let ctrl = ctrl.clone(); move |pid, nid, ptype, x, y| { ctrl.handle_pin_position(pid, nid, ptype, x, y); @@ -186,12 +200,12 @@ fn main() { } }); - window.on_update_viewport({ + window.global::().on_viewport_changed({ let ctrl = ctrl.clone(); let w = w.clone(); move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); + 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)); } } diff --git a/examples/animated-links/ui/animated-links.slint b/examples/animated-links/ui/animated-links.slint index 161207f..4e80d58 100644 --- a/examples/animated-links/ui/animated-links.slint +++ b/examples/animated-links/ui/animated-links.slint @@ -1,4 +1,4 @@ -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 +21,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 { @@ -128,14 +128,10 @@ 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); } @@ -148,20 +144,14 @@ 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); } } - - callback report-position(int, int, int, length, length); } export component MainWindow inherits Window { @@ -174,7 +164,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 +171,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,10 +182,9 @@ 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); @@ -209,10 +197,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 +204,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; @@ -237,18 +221,9 @@ export component MainWindow inherits Window { 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 +234,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); - } } } diff --git a/examples/custom-shapes/src/main.rs b/examples/custom-shapes/src/main.rs index 046c5f4..17856c1 100644 --- a/examples/custom-shapes/src/main.rs +++ b/examples/custom-shapes/src/main.rs @@ -60,8 +60,8 @@ fn main() { }, ])))); - // Custom link path computation callback - window.on_compute_link_path({ + // Custom link path computation via global callback + window.global::().on_compute_link_path({ let ctrl = ctrl.clone(); let w = w.clone(); move |start_pin, end_pin, _version, _zoom: f32, _pan_x: f32, _pan_y: f32| { @@ -70,10 +70,6 @@ fn main() { 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 +77,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()), @@ -94,7 +89,6 @@ fn main() { if style == "orthogonal" { generate_manhattan_path(sx, sy, ex, ey, zoom).into() } else { - // Fallback to standard Bezier (using library helper) slint_node_editor::generate_bezier_path(sx, sy, ex, ey, zoom, bezier_offset).into() } } else { @@ -106,40 +100,42 @@ fn main() { } }); - // Standard callbacks via controller - window.on_node_drag_started(ctrl.node_drag_started_callback()); - - window.on_node_rect_changed({ + // Geometry callbacks via globals + window.global::().on_report_node_rect({ let ctrl = ctrl.clone(); move |id, x, y, width, h| { ctrl.handle_node_rect(id, x, y, width, h); } }); - window.on_pin_position_changed({ + window.global::().on_report_pin_position({ let ctrl = ctrl.clone(); move |pid, nid, ptype, x, y| { ctrl.handle_pin_position(pid, nid, ptype, x, y); } }); - window.on_request_grid_update({ + // Viewport changes via global + window.global::().on_viewport_changed({ let ctrl = ctrl.clone(); let w = w.clone(); - move || { + move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { - w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); + 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)); } } }); - window.on_update_viewport({ + // Standard callbacks + window.on_node_drag_started(ctrl.node_drag_started_callback()); + + window.on_request_grid_update({ let ctrl = ctrl.clone(); let w = w.clone(); - move |z, pan_x, pan_y| { + move || { 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)); + w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_())); } } }); diff --git a/examples/custom-shapes/ui/main.slint b/examples/custom-shapes/ui/main.slint index dfc6847..c4db2ae 100644 --- a/examples/custom-shapes/ui/main.slint +++ b/examples/custom-shapes/ui/main.slint @@ -1,4 +1,4 @@ -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 { @@ -8,72 +8,49 @@ export struct NodeData { y: float, } -export { LinkData } +export { LinkData, NodeEditorComputations, GeometryCallbacks } component SimpleNode inherits BaseNode { in property title: "Node"; - width: 150px ; - height: 100px ; + node-width: 150px; + node-height: 100px; Rectangle { + width: 100%; + height: 100%; background: root.selected ? #4a6a9a : #333; - border-radius: 4px ; - border-width: 1px ; + border-radius: 4px; + border-width: 1px; border-color: #666; Text { text: root.title; color: white; - font-size: 14px ; + font-size: 14px; } } // Input 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: 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: 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 +59,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 +73,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; } } 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/pin-compatibility/src/main.rs b/examples/pin-compatibility/src/main.rs index a9e5e58..387557f 100644 --- a/examples/pin-compatibility/src/main.rs +++ b/examples/pin-compatibility/src/main.rs @@ -190,8 +190,8 @@ fn main() { // Link ID counter let next_link_id = Rc::new(std::cell::Cell::new(1)); - // Core callbacks - window.on_compute_link_path({ + // Core callbacks via globals + window.global::().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| { @@ -258,15 +258,15 @@ fn main() { } }); - // Geometry tracking - window.on_node_rect_changed({ + // Geometry tracking - using GeometryCallbacks global + window.global::().on_report_node_rect({ let ctrl = ctrl.clone(); move |id, x, y, width, h| { ctrl.handle_node_rect(id, x, y, width, h); } }); - window.on_pin_position_changed({ + window.global::().on_report_pin_position({ let ctrl = ctrl.clone(); move |pid, nid, ptype, x, y| { ctrl.handle_pin_position(pid, nid, ptype, x, y); @@ -353,12 +353,12 @@ fn main() { } }); - window.on_update_viewport({ + window.global::().on_viewport_changed({ let ctrl = ctrl.clone(); let w = w.clone(); move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); + 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)); } } diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index e99704e..1b66a82 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -1,4 +1,4 @@ -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, @@ -7,7 +7,7 @@ export struct NodeData { y: float, } -export { LinkData } +export { LinkData, NodeEditorComputations, GeometryCallbacks } export global DataTypes { out property execute: 0; @@ -44,20 +44,18 @@ 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 * ViewportState.zoom; width: pin-size; height: pin-size; @@ -66,15 +64,13 @@ 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; 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); } + // Forward drag events (pin auto-reports position via globals) 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); } @@ -112,7 +108,6 @@ component SourceNode inherits BaseNode { width: L.node-width ; height: L.node-height ; - callback report-position(int, int, int, length, length); Rectangle { background: root.selected ? #4a6a9a : #2d2d2d; @@ -144,10 +139,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -155,10 +149,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -166,10 +159,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -177,10 +169,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -188,10 +179,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -199,10 +189,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -210,10 +199,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -221,10 +209,9 @@ component SourceNode inherits BaseNode { ValidatedPin { x: parent.width - self.width; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -239,7 +226,6 @@ component SinkNode inherits BaseNode { width: L.node-width ; height: L.node-height ; - callback report-position(int, int, int, length, length); Rectangle { background: root.selected ? #4a6a9a : #2d2d2d; @@ -271,10 +257,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -282,10 +267,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -293,10 +277,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -304,10 +287,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -315,10 +297,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -326,10 +307,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -337,10 +317,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -348,10 +327,9 @@ component SinkNode inherits BaseNode { ValidatedPin { x: 0px; 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; zoom: root.zoom; refresh-trigger: root.pin-refresh-trigger; + 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; - 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); } @@ -374,17 +352,15 @@ 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); + // 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,28 +401,17 @@ 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; + viewport-width: editor.width; + viewport-height: editor.height; - 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); @@ -467,19 +432,11 @@ 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; + viewport-width: editor.width; + viewport-height: editor.height; - 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); diff --git a/examples/zoom-stress-test/src/main.rs b/examples/zoom-stress-test/src/main.rs index cb10af6..4e2ca1c 100644 --- a/examples/zoom-stress-test/src/main.rs +++ b/examples/zoom-stress-test/src/main.rs @@ -81,18 +81,18 @@ 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()); + // Core callbacks - controller handles link path computation via global + window.global::().on_compute_link_path(ctrl.compute_link_path_callback()); - // Geometry tracking - window.on_node_rect_changed({ + // Geometry tracking via globals + window.global::().on_report_node_rect({ let ctrl = ctrl.clone(); move |id, x, y, width, h| { ctrl.handle_node_rect(id, x, y, width, h); } }); - window.on_pin_position_changed({ + window.global::().on_report_pin_position({ let ctrl = ctrl.clone(); move |pid, nid, ptype, x, y| { ctrl.handle_pin_position(pid, nid, ptype, x, y); @@ -110,12 +110,13 @@ fn main() { } }); - window.on_update_viewport({ + // Viewport change handling via global + window.global::().on_viewport_changed({ let ctrl = ctrl.clone(); let w = w.clone(); move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { - ctrl.set_zoom(z); + 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)); } } diff --git a/examples/zoom-stress-test/ui/ui.slint b/examples/zoom-stress-test/ui/ui.slint index 4aaf2e3..55852c4 100644 --- a/examples/zoom-stress-test/ui/ui.slint +++ b/examples/zoom-stress-test/ui/ui.slint @@ -11,6 +11,8 @@ 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"; @@ -18,7 +20,7 @@ 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"; @@ -48,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); @@ -92,10 +91,6 @@ 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; diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index a9d8dcf..5da596d 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -51,6 +51,21 @@ export global GeometryVersion { 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; +} + /// 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. @@ -87,27 +102,23 @@ export global NodeEditorComputations { /* geometry-version */ int, /* zoom */ float, /* pan-x */ float, - /* pan-y */ float - ) -> string; + /* pan-y */ float) -> string; /// Viewport state changed (zoom or pan) callback viewport-changed( /* zoom */ float, /* pan-x */ float, - /* pan-y */ float - ); + /* pan-y */ float); /// Check if a node is selected pure callback is-node-selected( /* node-id */ int, - /* selection-version */ int - ) -> bool; + /* selection-version */ int) -> bool; /// Check if a link is selected pure callback is-link-selected( /* link-id */ int, - /* selection-version */ int - ) -> bool; + /* selection-version */ int) -> bool; } /// Optional layout helpers for common node structures. @@ -638,6 +649,7 @@ export component Pin inherits Rectangle { init => { if (pin-id > 0) { + GeometryVersion.version += 1; GeometryCallbacks.report-pin-position(pin-id, node-id, pin-type, center-x, center-y); } } @@ -668,20 +680,26 @@ export component Pin inherits Rectangle { 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 - GeometryCallbacks.start-link-from-pin(pin-id, node-screen-x + root.x + self.x + self.width / 2, node-screen-y + root.y + self.y + self.height / 2); + // 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 + (root.x + self.x + self.width / 2) * ViewportState.zoom; + LinkCreation.current-y = node-screen-y + (root.y + self.y + self.height / 2) * ViewportState.zoom; + LinkCreation.state = 1; // started + LinkCreation.version += 1; } if event.kind == PointerEventKind.up && drag-active { drag-active = false; - GeometryCallbacks.complete-link(); + LinkCreation.state = 3; // complete + LinkCreation.version += 1; } } moved => { if drag-active { - GeometryCallbacks.update-link-preview( - node-screen-x + root.x + self.mouse-x, - node-screen-y + root.y + self.mouse-y); + LinkCreation.current-x = node-screen-x + (root.x + self.mouse-x) * ViewportState.zoom; + LinkCreation.current-y = node-screen-y + (root.y + self.mouse-y) * ViewportState.zoom; + LinkCreation.state = 2; // updating + LinkCreation.version += 1; } } } diff --git a/node-editor.slint b/node-editor.slint index 7be1359..a2e4966 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -36,6 +36,7 @@ import { DragState, GeometryVersion, GeometryCallbacks, + LinkCreation, NodeEditorComputations, NodeStyleDefaults, MinimapNode, @@ -46,7 +47,7 @@ import { BaseNode, Pin, } from "node-editor-building-blocks.slint"; -export { PinTypes, ViewportState, DragState, GeometryVersion, GeometryCallbacks, NodeEditorComputations, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } +export { PinTypes, ViewportState, DragState, GeometryVersion, GeometryCallbacks, LinkCreation, NodeEditorComputations, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } /// Modifier key for triggering box selection over nodes/links export enum BoxSelectionModifier { @@ -439,6 +440,22 @@ export component NodeEditor { changed height => { request-grid-update(); } + + // 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 + } + } /// Call this after initial geometry has been reported to force link path recomputation. public function refresh-links() { diff --git a/tests/common/harness.rs b/tests/common/harness.rs index d6db5a7..3f3c81c 100644 --- a/tests/common/harness.rs +++ b/tests/common/harness.rs @@ -105,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| { @@ -115,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| { @@ -244,7 +244,8 @@ impl MinimalTestHarness { } }); - 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) }); diff --git a/tests/ui/test.slint b/tests/ui/test.slint index 095fae9..b358bc8 100644 --- a/tests/ui/test.slint +++ b/tests/ui/test.slint @@ -51,9 +51,6 @@ component SimpleNode inherits BaseNode { pin-type: PinTypes.input; 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 @@ -65,12 +62,7 @@ component SimpleNode inherits BaseNode { pin-type: PinTypes.output; 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 { @@ -81,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; @@ -93,8 +84,6 @@ 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 node-drag-started <=> editor.node-drag-started; callback node-drag-ended <=> editor.node-drag-ended; @@ -114,7 +103,7 @@ export component MainWindow inherits Window { callback sync-selection-to-nodes <=> editor.sync-selection-to-nodes; callback sync-selection-to-links <=> editor.sync-selection-to-links; - // 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; @@ -135,25 +124,8 @@ export component MainWindow inherits Window { title: data.title; world-x: data.x * 1px; world-y: data.y * 1px; - // drag-offset-x and drag-offset-y computed automatically in BaseNode - - 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; } } } From d8b9e7889aacb297ea960e9ff449a8c55dd2c573 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 15:32:53 +0000 Subject: [PATCH 11/22] link compat demo fixed --- SIMPLIFICATION_PLAN.md | 124 ++++++++++++++++++ examples/animated-links/src/main.rs | 78 ++++------- examples/custom-shapes/src/main.rs | 68 ++++------ examples/pin-compatibility/src/main.rs | 83 ++++-------- .../ui/pin-compatibility.slint | 92 +------------ examples/zoom-stress-test/src/main.rs | 27 ++-- node-editor-building-blocks.slint | 38 +++--- 7 files changed, 233 insertions(+), 277 deletions(-) create mode 100644 SIMPLIFICATION_PLAN.md diff --git a/SIMPLIFICATION_PLAN.md b/SIMPLIFICATION_PLAN.md new file mode 100644 index 0000000..f6b7f6f --- /dev/null +++ b/SIMPLIFICATION_PLAN.md @@ -0,0 +1,124 @@ +# 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 ✓ IN PROGRESS +**Goal:** NodeEditorSetup (or new helper) handles all geometry callbacks automatically + +**Changes:** +1. Extend `wire_node_editor!` macro or NodeEditorSetup init +2. Automatically wire `on_report_node_rect` and `on_report_pin_position` to controller +3. Remove from all examples: manual global geometry callback setup + +**Impact:** Removes 2 explicit callback setups from all examples + +--- + +## 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) - Easy, affects all examples, 2 callbacks removed +2. **Phase 1** (Grid) - Easy, affects all examples, 2-3 callbacks removed +3. **Phase 2** (Link paths) - Medium, affects most examples, 1 callback removed +4. **Phase 6** (Drag) - Easy, wiring already exists, 2 callbacks removed +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/animated-links/src/main.rs b/examples/animated-links/src/main.rs index e74b1d3..4d3df35 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,33 +63,35 @@ fn main() { Color::from_argb_u8(255, 52, 211, 153), // Green ]; - // Core callbacks via globals - window.global::().on_compute_link_path({ - let ctrl = ctrl.clone(); - let w = window.as_weak(); - move |start_pin, end_pin, _version, _zoom, _pan_x, _pan_y| { - 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()); + + // Wire all standard callbacks with one macro call + wire_node_editor!(window, setup); // Pin hit testing window.on_compute_pin_at({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().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(); + let ctrl = setup.controller().clone(); move |start_x, start_y, end_x, end_y| { slint_node_editor::generate_bezier_path( start_x as f32, @@ -106,7 +107,7 @@ fn main() { // 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(); @@ -174,24 +175,9 @@ fn main() { } }); - // Geometry tracking - use GeometryCallbacks global - window.global::().on_report_node_rect({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.global::().on_report_pin_position({ - 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 ctrl = setup.controller().clone(); let w = w.clone(); move || { if let Some(w) = w.upgrade() { @@ -201,7 +187,7 @@ fn main() { }); window.global::().on_viewport_changed({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = w.clone(); move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { @@ -211,24 +197,6 @@ fn main() { } }); - // 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/custom-shapes/src/main.rs b/examples/custom-shapes/src/main.rs index 17856c1..ad1bcf2 100644 --- a/examples/custom-shapes/src/main.rs +++ b/examples/custom-shapes/src/main.rs @@ -1,5 +1,5 @@ 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!(); @@ -31,7 +31,6 @@ fn generate_manhattan_path( fn main() { let window = MainWindow::new().unwrap(); - let ctrl = NodeEditorController::new(); let w = window.as_weak(); // Set up nodes @@ -60,9 +59,31 @@ fn main() { }, ])))); + // 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); + // 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 = ctrl.clone(); + 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() }; @@ -100,24 +121,9 @@ fn main() { } }); - // Geometry callbacks via globals - window.global::().on_report_node_rect({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.global::().on_report_pin_position({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); - // Viewport changes via global window.global::().on_viewport_changed({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = w.clone(); move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { @@ -127,11 +133,9 @@ fn main() { } }); - // Standard callbacks - window.on_node_drag_started(ctrl.node_drag_started_callback()); - + // Grid initialization window.on_request_grid_update({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = w.clone(); move || { if let Some(w) = w.upgrade() { @@ -140,24 +144,6 @@ fn main() { } }); - // 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/pin-compatibility/src/main.rs b/examples/pin-compatibility/src/main.rs index 387557f..4f64929 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,7 +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 @@ -190,35 +189,39 @@ fn main() { // Link ID counter let next_link_id = Rc::new(std::cell::Cell::new(1)); - // Core callbacks via globals - window.global::().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()); + + // Wire all standard callbacks with one macro call + wire_node_editor!(window, setup); // Pin hit detection for link completion window.on_compute_pin_at({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); move |x, y| { - ctrl.cache().borrow().find_pin_at(x as f32, y as f32, 20.0) + let result = ctrl.cache().borrow().find_pin_at(x as f32, y as f32, 20.0); + println!("compute_pin_at({}, {}) = {}", x, y, result); + result } }); // 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(); @@ -242,6 +245,7 @@ fn main() { window.on_compute_link_preview_path({ let w = window.as_weak(); move |start_x, start_y, end_x, end_y| { + println!("compute_link_preview_path({}, {}, {}, {})", start_x, start_y, end_x, end_y); let w = match w.upgrade() { Some(w) => w, None => return SharedString::default(), @@ -258,24 +262,9 @@ fn main() { } }); - // Geometry tracking - using GeometryCallbacks global - window.global::().on_report_node_rect({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } - }); - - window.global::().on_report_pin_position({ - 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| { @@ -344,7 +333,7 @@ fn main() { // Grid updates window.on_request_grid_update({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = w.clone(); move || { if let Some(w) = w.upgrade() { @@ -354,7 +343,7 @@ fn main() { }); window.global::().on_viewport_changed({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = w.clone(); move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { @@ -364,24 +353,6 @@ fn main() { } }); - // 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"); diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index 1b66a82..12302dd 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -50,10 +50,6 @@ component ValidatedPin inherits Rectangle { in property refresh-trigger: 0; in property valid-target-pin-id: 0; // Pin ID that's a valid drop target - 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 * ViewportState.zoom; @@ -65,15 +61,11 @@ component ValidatedPin inherits Rectangle { node-id: root.node-id; pin-type: root.pin-type; 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 + root.x * ViewportState.zoom; + node-screen-y: root.node-screen-y + root.y * ViewportState.zoom; + parent-offset-x: root.x; + parent-offset-y: root.y; refresh-trigger: root.refresh-trigger; - - // Forward drag events (pin auto-reports position via globals) - 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 @@ -142,9 +134,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: parent.width - self.width; @@ -152,9 +141,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: parent.width - self.width; @@ -162,9 +148,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: parent.width - self.width; @@ -172,9 +155,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: parent.width - self.width; @@ -182,9 +162,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: parent.width - self.width; @@ -192,9 +169,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: parent.width - self.width; @@ -202,9 +176,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: parent.width - self.width; @@ -212,9 +183,6 @@ component SourceNode inherits BaseNode { 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; - 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); } } } @@ -260,9 +228,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: 0px; @@ -270,9 +235,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: 0px; @@ -280,9 +242,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: 0px; @@ -290,9 +249,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: 0px; @@ -300,9 +256,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: 0px; @@ -310,9 +263,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: 0px; @@ -320,9 +270,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } ValidatedPin { x: 0px; @@ -330,9 +277,6 @@ component SinkNode inherits BaseNode { 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; - 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); } } } @@ -411,20 +355,6 @@ export component MainWindow inherits Window { valid-target-pin-id: root.valid-target-pin-id; viewport-width: editor.width; viewport-height: editor.height; - - 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(); - } } SinkNode { @@ -436,20 +366,6 @@ export component MainWindow inherits Window { valid-target-pin-id: root.valid-target-pin-id; viewport-width: editor.width; viewport-height: editor.height; - - 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(); - } } } diff --git a/examples/zoom-stress-test/src/main.rs b/examples/zoom-stress-test/src/main.rs index 4e2ca1c..a9a5b66 100644 --- a/examples/zoom-stress-test/src/main.rs +++ b/examples/zoom-stress-test/src/main.rs @@ -3,14 +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 @@ -81,27 +80,17 @@ 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 via global - window.global::().on_compute_link_path(ctrl.compute_link_path_callback()); - - // Geometry tracking via globals - window.global::().on_report_node_rect({ - let ctrl = ctrl.clone(); - move |id, x, y, width, h| { - ctrl.handle_node_rect(id, x, y, width, h); - } + // 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 }); - window.global::().on_report_pin_position({ - let ctrl = ctrl.clone(); - move |pid, nid, ptype, x, y| { - ctrl.handle_pin_position(pid, nid, ptype, x, y); - } - }); + // Wire all standard callbacks with one macro call + wire_node_editor!(window, setup); // Grid updates window.on_request_grid_update({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = w.clone(); move || { if let Some(w) = w.upgrade() { @@ -112,7 +101,7 @@ fn main() { // Viewport change handling via global window.global::().on_viewport_changed({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = w.clone(); move |z, pan_x, pan_y| { if let Some(w) = w.upgrade() { diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 5da596d..f492e3a 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -625,6 +625,8 @@ export component Pin inherits Rectangle { in property base-size: 12px; 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; @@ -633,9 +635,9 @@ export component Pin inherits Rectangle { property pin-radius: pin-size / 2; /// Pin center X relative to node top-left - out property center-x: self.x + self.width / 2; + out property center-x: parent-offset-x + self.x + self.width / 2; /// Pin center Y relative to node top-left - out property center-y: self.y + self.height / 2; + out property center-y: parent-offset-y + self.y + self.height / 2; callback drag-started(int, length, length); callback drag-moved(int, length, length); @@ -675,11 +677,16 @@ export component Pin inherits Rectangle { } touch := TouchArea { - property drag-active: false; - + moved => { + if LinkCreation.state > 0 { + LinkCreation.current-x = node-screen-x + (root.x + self.mouse-x) * ViewportState.zoom; + LinkCreation.current-y = node-screen-y + (root.y + self.mouse-y) * ViewportState.zoom; + LinkCreation.state = 2; // updating + LinkCreation.version += 1; + } + } pointer-event(event) => { if event.kind == PointerEventKind.down && event.button == PointerEventButton.left { - drag-active = true; // 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; @@ -687,19 +694,14 @@ export component Pin inherits Rectangle { LinkCreation.current-y = node-screen-y + (root.y + self.y + self.height / 2) * ViewportState.zoom; LinkCreation.state = 1; // started LinkCreation.version += 1; - } - if event.kind == PointerEventKind.up && drag-active { - drag-active = false; - LinkCreation.state = 3; // complete - LinkCreation.version += 1; - } - } - moved => { - if drag-active { - LinkCreation.current-x = node-screen-x + (root.x + self.mouse-x) * ViewportState.zoom; - LinkCreation.current-y = node-screen-y + (root.y + self.mouse-y) * ViewportState.zoom; - LinkCreation.state = 2; // updating - 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 + (root.x + self.mouse-x) * ViewportState.zoom; + LinkCreation.current-y = node-screen-y + (root.y + self.mouse-y) * ViewportState.zoom; + LinkCreation.state = 3; // complete + LinkCreation.version += 1; + } } } } From 7513c71a85157be2718c6cf02dc4aae37fa17c3f Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 15:41:03 +0000 Subject: [PATCH 12/22] phase 5 simplifaction complete --- SIMPLIFICATION_PLAN.md | 47 ++- examples/advanced/src/main.rs | 72 ++-- examples/advanced/ui/filter_node.slint | 82 ++--- examples/advanced/ui/ui.slint | 18 +- .../animated-links/ui/animated-links.slint | 129 +++++-- examples/custom-shapes/ui/main.slint | 12 +- .../ui/pin-compatibility.slint | 323 ++++++++++++------ 7 files changed, 435 insertions(+), 248 deletions(-) diff --git a/SIMPLIFICATION_PLAN.md b/SIMPLIFICATION_PLAN.md index f6b7f6f..f63c3ba 100644 --- a/SIMPLIFICATION_PLAN.md +++ b/SIMPLIFICATION_PLAN.md @@ -3,9 +3,11 @@ 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 @@ -16,10 +18,12 @@ Goal: Eliminate all node editor mechanics from examples, leaving only domain-spe --- ## 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) + +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 @@ -30,9 +34,11 @@ Goal: Eliminate all node editor mechanics from examples, leaving only domain-spe --- ## 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 @@ -43,11 +49,13 @@ Goal: Eliminate all node editor mechanics from examples, leaving only domain-spe --- ## 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` +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` @@ -55,22 +63,32 @@ Goal: Eliminate all node editor mechanics from examples, leaving only domain-spe --- -## Phase 5: Geometry Tracking Consolidation ✓ IN PROGRESS +## Phase 5: Geometry Tracking Consolidation ✓ COMPLETED + **Goal:** NodeEditorSetup (or new helper) handles all geometry callbacks automatically **Changes:** -1. Extend `wire_node_editor!` macro or NodeEditorSetup init -2. Automatically wire `on_report_node_rect` and `on_report_pin_position` to controller -3. Remove from all examples: manual global geometry callback setup -**Impact:** Removes 2 explicit callback setups from all examples +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 +## 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) @@ -81,10 +99,12 @@ Goal: Eliminate all node editor mechanics from examples, leaving only domain-spe --- ## 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` + +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` @@ -115,10 +135,11 @@ window.run().unwrap(); ## Implementation Order Priority by impact/simplicity ratio: -1. ✓ **Phase 5** (Geometry) - Easy, affects all examples, 2 callbacks removed -2. **Phase 1** (Grid) - Easy, affects all examples, 2-3 callbacks removed -3. **Phase 2** (Link paths) - Medium, affects most examples, 1 callback removed -4. **Phase 6** (Drag) - Easy, wiring already exists, 2 callbacks removed + +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 93d6e66..488357b 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,8 +280,11 @@ fn main() { // === Computation Callbacks === + // Wire standard callbacks with one macro call + wire_node_editor!(window, setup); + window.on_request_grid_update({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = window.as_weak(); move || { if let Some(w) = w.upgrade() { @@ -278,22 +293,8 @@ fn main() { } }); - window.global::().on_report_pin_position({ - 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.global::().on_report_node_rect({ - 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 ctrl = setup.controller().clone(); let w = window.as_weak(); move |x, y| { let w = match w.upgrade() { Some(w) => w, None => return 0 }; @@ -302,7 +303,7 @@ fn main() { }); 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| { @@ -315,14 +316,14 @@ fn main() { }); window.on_compute_box_selection({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().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,18 +333,6 @@ fn main() { } }); - window.global::().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() }; @@ -436,7 +425,7 @@ fn main() { // === 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(); @@ -481,7 +470,7 @@ fn main() { // Viewport change handling (grid updates) - now via NodeEditorComputations global window.global::().on_viewport_changed({ - let ctrl = ctrl.clone(); + let ctrl = setup.controller().clone(); let w = window.as_weak(); move |zoom, pan_x, pan_y| { let w = match w.upgrade() { Some(w) => w, None => return }; @@ -492,17 +481,8 @@ fn main() { } }); - 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 d79655e..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 : 10px ; // 20% bigger when pulsed (20 * 1.2 = 24) - height: pulse-state ? 24px : 10px ; + 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 ; + 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) ; - property row-2-center: (content-padding + title-height + content-padding + row-height + row-height / 2) ; + 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 ; - height: base-height ; + width: base-width; + height: base-height; // Main layout VerticalLayout { - padding: root.content-padding ; + padding: root.content-padding; spacing: 0px; // Title bar Rectangle { - height: root.title-height ; + height: root.title-height; background: root.selected ? #6a4a9a : #4a3d5d; - border-radius: 4px ; + border-radius: 4px; Text { text: root.title; color: white; - font-size: 14px ; + 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 ; + padding-top: root.content-padding; // Left pin labels column VerticalLayout { - width: pin-area-width ; + width: pin-area-width; spacing: 0px; // Row 1: Data input label Rectangle { - height: row-height ; + height: row-height; Text { - x: 16px ; + x: 16px; text: "In"; color: #888; - font-size: 10px ; + font-size: 10px; vertical-alignment: center; } } // Row 2: Control input label Rectangle { - height: row-height ; + height: row-height; Text { - x: 16px ; + x: 16px; text: "Ctrl"; color: #888; - font-size: 10px ; + font-size: 10px; vertical-alignment: center; } } // Row 3: empty Rectangle { - height: row-height ; + height: row-height; } } // Center content VerticalLayout { - spacing: 4px ; + spacing: 4px; horizontal-stretch: 1; // Row 1: Filter type selector HorizontalLayout { - height: row-height ; - spacing: 8px ; + height: row-height; + spacing: 8px; alignment: stretch; - padding-top: 4px ; - padding-bottom: 4px ; + padding-top: 4px; + padding-bottom: 4px; Text { text: "Type:"; color: #aaa; - font-size: 11px ; + font-size: 11px; vertical-alignment: center; } @@ -193,34 +193,34 @@ export component FilterNode inherits BaseNode { // Row 2: Status row HorizontalLayout { - height: row-height ; - spacing: 12px ; + height: row-height; + spacing: 12px; alignment: start; - padding-top: 4px ; - padding-bottom: 4px ; + padding-top: 4px; + padding-bottom: 4px; Text { text: root.enabled ? "Active" : "Bypassed"; color: root.enabled ? #8f8 : #f88; - font-size: 12px ; + font-size: 12px; vertical-alignment: center; } Text { text: "Count: " + root.processed-count; color: #888; - font-size: 11px ; + font-size: 11px; vertical-alignment: center; } } // Row 3: Button row HorizontalLayout { - height: row-height ; - spacing: 8px ; + height: row-height; + spacing: 8px; alignment: start; - padding-top: 2px ; - padding-bottom: 2px ; + 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 ; + width: pin-area-width; spacing: 0px; // Row 1: Data output label Rectangle { - height: row-height ; + height: row-height; Text { x: 0px; - width: parent.width - 16px ; + width: parent.width - 16px; text: "Out"; color: #888; - font-size: 10px ; + font-size: 10px; vertical-alignment: center; horizontal-alignment: right; } @@ -259,7 +259,7 @@ export component FilterNode inherits BaseNode { // Rows 2-3: empty Rectangle { - height: row-height * 2; + height: row-height * 2; } } } diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index eb157ad..ebf0c2f 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -82,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 ; + property pin-size: NodeConstants.pin-size; property pin-radius: pin-size / 2; // Base dimensions (from global constants) @@ -96,22 +96,22 @@ component Node inherits BaseNode { out property output-pin-y: self.screen-y + output-pin.center-y; // Set dimensions - width: base-width ; - height: base-height ; + width: base-width; + height: base-height; // Title bar Rectangle { - x: 8px ; - y: 8px ; - width: parent.width - 16px ; - height: 24px ; + x: 8px; + y: 8px; + width: parent.width - 16px; + height: 24px; background: root.selected ? #4a6a9a : #3d3d3d; - border-radius: 4px ; + border-radius: 4px; Text { text: root.title; color: white; - font-size: 14px ; + font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } diff --git a/examples/animated-links/ui/animated-links.slint b/examples/animated-links/ui/animated-links.slint index 4e80d58..b49fb2b 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, NodeEditorComputations, GeometryCallbacks, ViewportState, GeometryVersion } 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, @@ -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 ; - height: 100px ; + width: 150px; + height: 100px; Rectangle { background: root.selected ? accent-color.darker(60%) : #333; - border-radius: 6px ; - border-width: root.selected ? 2px : 1px ; + 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 ; - border-radius: 6px ; + 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 ; + y: parent.height - 6px; width: parent.width; - height: 6px ; + 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 ; + font-size: 14px; font-weight: 600; } } @@ -132,9 +139,15 @@ component SimpleNode inherits BaseNode { hover-color: accent-color; node-screen-x: root.screen-x; node-screen-y: root.screen-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,9 +161,15 @@ component SimpleNode inherits BaseNode { hover-color: accent-color; node-screen-x: root.screen-x; node-screen-y: root.screen-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); + } } } @@ -215,10 +234,7 @@ 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; viewport-width: root.width; @@ -280,10 +296,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; + } } } @@ -297,11 +333,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/ui/main.slint b/examples/custom-shapes/ui/main.slint index c4db2ae..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, NodeEditorComputations, GeometryCallbacks } 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,7 +15,6 @@ export struct NodeData { x: float, y: float, } - export { LinkData, NodeEditorComputations, GeometryCallbacks } component SimpleNode inherits BaseNode { @@ -111,7 +118,6 @@ export component MainWindow inherits Window { HorizontalLayout { padding: 10px; spacing: 10px; - Text { text: "Link Style:"; color: white; diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index 12302dd..2ec8467 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, NodeEditorComputations, GeometryCallbacks, ViewportState } 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,7 +15,6 @@ export struct NodeData { x: float, y: float, } - export { LinkData, NodeEditorComputations, GeometryCallbacks } export global DataTypes { @@ -72,20 +80,20 @@ component ValidatedPin inherits Rectangle { Rectangle { visible: root.is-valid-target; x: root.pin-size; - y: -4px ; - width: 16px ; - height: 16px ; + y: -4px; + width: 16px; + height: 16px; background: #22c55e; border-radius: self.width / 2; // Checkmark using Path Path { - x: 3px ; - y: 4px ; - width: 10px ; - height: 8px ; + x: 3px; + y: 4px; + width: 10px; + height: 8px; stroke: white; - stroke-width: 2px ; + stroke-width: 2px; fill: transparent; commands: "M 0 4 L 3 7 L 9 1"; } @@ -97,92 +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 ; - height: L.node-height ; - + width: L.node-width; + height: L.node-height; Rectangle { background: root.selected ? #4a6a9a : #2d2d2d; - border-radius: 6px ; - border-width: 1px ; + border-radius: 6px; + border-width: 1px; border-color: #555; // Title Text { - x: 8px ; - y: 6px ; + x: 8px; + y: 6px; text: root.title; color: white; - font-size: 12px ; + font-size: 12px; font-weight: 600; } // Pin labels for label[idx] in ["Execute", "Integer", "Float", "String", "Boolean", "Object", "Array", "Any"]: Text { - x: 8px ; - y: (L.first-pin-y + idx * L.pin-spacing) ; + x: 8px; + y: (L.first-pin-y + idx * L.pin-spacing); text: label; color: #aaa; - font-size: 10px ; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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; } } @@ -191,92 +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 ; - height: L.node-height ; - + width: L.node-width; + height: L.node-height; Rectangle { background: root.selected ? #4a6a9a : #2d2d2d; - border-radius: 6px ; - border-width: 1px ; + border-radius: 6px; + border-width: 1px; border-color: #555; // Title Text { - x: 8px ; - y: 6px ; + x: 8px; + y: 6px; text: root.title; color: white; - font-size: 12px ; + font-size: 12px; font-weight: 600; } // Pin labels for label[idx] in ["Execute", "Integer", "Float", "String", "Boolean", "Object", "Array", "Any"]: Text { - x: 20px ; - y: (L.first-pin-y + idx * L.pin-spacing) ; + x: 20px; + y: (L.first-pin-y + idx * L.pin-spacing); text: label; color: #aaa; - font-size: 10px ; + font-size: 10px; } } // Input pins - direct children of node ValidatedPin { x: 0px; - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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) - 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; + 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; } } @@ -301,7 +401,9 @@ export component MainWindow inherits Window { 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(); } + 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; @@ -371,17 +473,34 @@ export component MainWindow inherits Window { // 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; + } } } } From 2710ecd91e116b23408853637baf857055c34ad2 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 16:30:12 +0000 Subject: [PATCH 13/22] phase 1 simplifaction complete --- examples/advanced/src/main.rs | 23 -------------- examples/animated-links/src/main.rs | 22 ------------- examples/custom-shapes/src/main.rs | 44 ++++---------------------- examples/minimal/src/main.rs | 16 ---------- examples/pin-compatibility/src/main.rs | 24 -------------- examples/sugiyama/src/main.rs | 17 ---------- examples/zoom-stress-test/src/main.rs | 24 -------------- node-editor.slint | 7 ++-- src/lib.rs | 17 ++++++++++ 9 files changed, 25 insertions(+), 169 deletions(-) diff --git a/examples/advanced/src/main.rs b/examples/advanced/src/main.rs index 488357b..2789b30 100644 --- a/examples/advanced/src/main.rs +++ b/examples/advanced/src/main.rs @@ -283,16 +283,6 @@ fn main() { // Wire standard callbacks with one macro call wire_node_editor!(window, setup); - window.on_request_grid_update({ - let ctrl = setup.controller().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_compute_pin_at({ let ctrl = setup.controller().clone(); let w = window.as_weak(); @@ -468,19 +458,6 @@ fn main() { } }); - // Viewport change handling (grid updates) - now via NodeEditorComputations global - window.global::().on_viewport_changed({ - let ctrl = setup.controller().clone(); - let w = window.as_weak(); - move |zoom, pan_x, pan_y| { - let w = match w.upgrade() { Some(w) => w, None => return }; - ctrl.set_viewport(zoom, pan_x, pan_y); - - // Update grid - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - }); - window.on_delete_selected_nodes({ let ctrl = setup.controller().clone(); let nodes = nodes.clone(); diff --git a/examples/animated-links/src/main.rs b/examples/animated-links/src/main.rs index 4d3df35..951eb58 100644 --- a/examples/animated-links/src/main.rs +++ b/examples/animated-links/src/main.rs @@ -175,28 +175,6 @@ fn main() { } }); - // Grid updates - window.on_request_grid_update({ - let ctrl = setup.controller().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.global::().on_viewport_changed({ - let ctrl = setup.controller().clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - 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)); - } - } - }); - // Animation timer - updates animation time and link progress let animation_timer = Timer::default(); animation_timer.start( diff --git a/examples/custom-shapes/src/main.rs b/examples/custom-shapes/src/main.rs index ad1bcf2..1b86170 100644 --- a/examples/custom-shapes/src/main.rs +++ b/examples/custom-shapes/src/main.rs @@ -5,28 +5,22 @@ 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() { @@ -76,8 +70,6 @@ fn main() { } }); - // Wire all standard callbacks with one macro call - wire_node_editor!(window, setup); // Wire all standard callbacks with one macro call wire_node_editor!(window, setup); @@ -88,7 +80,6 @@ fn main() { 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(); let cache = ctrl.cache(); @@ -108,9 +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 { - 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() @@ -121,29 +113,5 @@ fn main() { } }); - // Viewport changes via global - window.global::().on_viewport_changed({ - let ctrl = setup.controller().clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - 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)); - } - } - }); - - // Grid initialization - window.on_request_grid_update({ - let ctrl = setup.controller().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.invoke_request_grid_update(); window.run().unwrap(); } diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index e5b1e7a..2219b59 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -6,7 +6,6 @@ slint::include_modules!(); fn main() { let window = MainWindow::new().unwrap(); - let w = window.as_weak(); // Set up nodes let nodes = Rc::new(VecModel::from(vec![ @@ -45,21 +44,6 @@ fn main() { // Wire all callbacks with one macro call wire_node_editor!(window, setup); - - // Viewport change handling (for grid updates) - window.global::().on_viewport_changed({ - let ctrl = setup.controller().clone(); - let w = w.clone(); - move |zoom, pan_x, pan_y| { - if let Some(w) = w.upgrade() { - ctrl.set_viewport(zoom, pan_x, pan_y); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - } - }); - - // Initial grid generation - window.set_grid_commands(setup.generate_initial_grid(window.get_width_(), window.get_height_())); window.run().unwrap(); } diff --git a/examples/pin-compatibility/src/main.rs b/examples/pin-compatibility/src/main.rs index 4f64929..14b3f38 100644 --- a/examples/pin-compatibility/src/main.rs +++ b/examples/pin-compatibility/src/main.rs @@ -163,7 +163,6 @@ impl LinkModel for LinkData { fn main() { let window = MainWindow::new().unwrap(); - let w = window.as_weak(); // Set up nodes let nodes = Rc::new(VecModel::from(vec![ @@ -331,28 +330,6 @@ fn main() { } }); - // Grid updates - window.on_request_grid_update({ - let ctrl = setup.controller().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.global::().on_viewport_changed({ - let ctrl = setup.controller().clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - 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)); - } - } - }); - // Print compatibility matrix on startup println!("\n=== Pin Type Compatibility Matrix ==="); println!("Try connecting pins to see validation in action!\n"); @@ -367,6 +344,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/sugiyama/src/main.rs b/examples/sugiyama/src/main.rs index f7f0d8d..a224bb3 100644 --- a/examples/sugiyama/src/main.rs +++ b/examples/sugiyama/src/main.rs @@ -156,23 +156,6 @@ fn main() { // Wire all callbacks with one macro call wire_node_editor!(window, setup); - - window.global::().on_viewport_changed({ - let ctrl = setup.controller().clone(); - let w = w.clone(); - move |zoom, pan_x, pan_y| { - if let Some(w) = w.upgrade() { - ctrl.set_viewport(zoom, pan_x, pan_y); - w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y)); - } - } - }); - - // Generate initial grid - window.set_grid_commands(setup.generate_initial_grid( - window.get_width_(), - window.get_height_(), - )); window.run().unwrap(); } diff --git a/examples/zoom-stress-test/src/main.rs b/examples/zoom-stress-test/src/main.rs index a9a5b66..3352103 100644 --- a/examples/zoom-stress-test/src/main.rs +++ b/examples/zoom-stress-test/src/main.rs @@ -10,7 +10,6 @@ slint::include_modules!(); fn main() { let window = MainWindow::new().unwrap(); - let w = window.as_weak(); // Create input nodes model let input_nodes: Rc> = Rc::new(VecModel::from(vec![InputNodeData { @@ -88,29 +87,6 @@ fn main() { // Wire all standard callbacks with one macro call wire_node_editor!(window, setup); - // Grid updates - window.on_request_grid_update({ - let ctrl = setup.controller().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_())); - } - } - }); - - // Viewport change handling via global - window.global::().on_viewport_changed({ - let ctrl = setup.controller().clone(); - let w = w.clone(); - move |z, pan_x, pan_y| { - 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)); - } - } - }); - // Input node callbacks window.on_input_text_changed(|id, val| { println!("Input node {}: text changed to '{}'", id, val); diff --git a/node-editor.slint b/node-editor.slint index a2e4966..b2f438d 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -406,18 +406,15 @@ export component NodeEditor { // === Trigger updates on viewport changes === changed pan-x => { ViewportState.pan-x = root.pan-x; - request-grid-update(); NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } changed pan-y => { ViewportState.pan-y = root.pan-y; - request-grid-update(); NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } changed zoom => { ViewportState.zoom = root.zoom; GeometryVersion.version += 1; - request-grid-update(); NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } @@ -435,10 +432,10 @@ export component NodeEditor { DragState.drag-offset-y = root.internal-drag-offset-y; } changed width => { - request-grid-update(); + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } changed height => { - request-grid-update(); + NodeEditorComputations.viewport-changed(root.zoom, root.pan-x / 1px, root.pan-y / 1px); } // Watch LinkCreation global for pin-initiated link creation diff --git a/src/lib.rs b/src/lib.rs index 4c062ff..3bfa1e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,5 +107,22 @@ macro_rules! wire_node_editor { 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()); + + // Wire viewport_changed for automatic 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)); + } + }); + + // Generate 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_())); + } }}; } From 7e6d8d9d7ac56bc651417fc5217d2491228406e1 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 16:46:40 +0000 Subject: [PATCH 14/22] phase 3 simplifaction complete --- examples/advanced/src/main.rs | 9 --------- examples/animated-links/src/main.rs | 6 ------ examples/pin-compatibility/src/main.rs | 10 ---------- node-editor-building-blocks.slint | 6 ++++++ node-editor.slint | 9 ++++++--- src/lib.rs | 10 +++++++++- 6 files changed, 21 insertions(+), 29 deletions(-) diff --git a/examples/advanced/src/main.rs b/examples/advanced/src/main.rs index 2789b30..b762402 100644 --- a/examples/advanced/src/main.rs +++ b/examples/advanced/src/main.rs @@ -283,15 +283,6 @@ fn main() { // Wire standard callbacks with one macro call wire_node_editor!(window, setup); - window.on_compute_pin_at({ - let ctrl = setup.controller().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) - } - }); - window.on_compute_link_at({ let ctrl = setup.controller().clone(); let links = links.clone(); diff --git a/examples/animated-links/src/main.rs b/examples/animated-links/src/main.rs index 951eb58..e3aa32a 100644 --- a/examples/animated-links/src/main.rs +++ b/examples/animated-links/src/main.rs @@ -83,12 +83,6 @@ fn main() { // Wire all standard callbacks with one macro call wire_node_editor!(window, setup); - // Pin hit testing - window.on_compute_pin_at({ - let ctrl = setup.controller().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 = setup.controller().clone(); diff --git a/examples/pin-compatibility/src/main.rs b/examples/pin-compatibility/src/main.rs index 14b3f38..b10ecb3 100644 --- a/examples/pin-compatibility/src/main.rs +++ b/examples/pin-compatibility/src/main.rs @@ -208,16 +208,6 @@ fn main() { // Wire all standard callbacks with one macro call wire_node_editor!(window, setup); - // Pin hit detection for link completion - window.on_compute_pin_at({ - let ctrl = setup.controller().clone(); - move |x, y| { - let result = ctrl.cache().borrow().find_pin_at(x as f32, y as f32, 20.0); - println!("compute_pin_at({}, {}) = {}", x, y, result); - result - } - }); - // Link validation callback for hover feedback window.on_validate_link({ let ctrl = setup.controller().clone(); diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index f492e3a..37c9f3d 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -119,6 +119,12 @@ export global NodeEditorComputations { 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; } /// Optional layout helpers for common node structures. diff --git a/node-editor.slint b/node-editor.slint index b2f438d..a3f1829 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -309,9 +309,12 @@ export component NodeEditor { 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; } } diff --git a/src/lib.rs b/src/lib.rs index 3bfa1e3..3db467a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,7 +106,15 @@ macro_rules! wire_node_editor { 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()); + + let computations = $window.global::(); + computations.on_compute_link_path($setup.compute_link_path()); + + // Wire compute-pin-at for automatic pin hit testing + let ctrl = $setup.controller().clone(); + computations.on_compute_pin_at(move |x, y, radius| { + ctrl.cache().borrow().find_pin_at(x, y, radius) + }); // Wire viewport_changed for automatic grid updates let ctrl = $setup.controller().clone(); From 41406fa92e8ce1c90ac407d04cee6efe56bb7a63 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 16:52:32 +0000 Subject: [PATCH 15/22] fixed zoom bug in advanced example --- examples/minimal/ui/minimal.slint | 4 ++-- node-editor-building-blocks.slint | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index 8be8287..e93d6ac 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -22,8 +22,8 @@ export { LinkData, DragState, GeometryCallbacks, NodeEditorComputations } component SimpleNode inherits BaseNode { in property title: "Node"; // Set node size for BaseNode - node-width: 150px; - node-height: 100px; + node-width: 250px; + node-height: 80px; // Visual content - Add any bespoke UI here! Rectangle { diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 37c9f3d..b871b34 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -614,7 +614,7 @@ export component BaseNode inherits Rectangle { GeometryCallbacks.update-node-drag(current-drag-offset-x, current-drag-offset-y); // Update geometry for live link updates GeometryVersion.version += 1; - GeometryCallbacks.report-node-rect(node-id, world-x + current-drag-offset-x, world-y + current-drag-offset-y, node-width, node-height); + GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } } From 8035d2a9620b90de9bac30eef85026e2356616a2 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 17:01:22 +0000 Subject: [PATCH 16/22] phase 4 simplifaction complete --- examples/advanced/src/main.rs | 6 ------ examples/advanced/ui/ui.slint | 3 +-- examples/animated-links/src/main.rs | 16 -------------- .../animated-links/ui/animated-links.slint | 1 - examples/pin-compatibility/src/main.rs | 21 ------------------- .../ui/pin-compatibility.slint | 1 - node-editor-building-blocks.slint | 9 ++++++++ node-editor.slint | 11 +++++----- src/lib.rs | 5 +++++ tests/common/harness.rs | 4 ---- tests/ui/test.slint | 1 - 11 files changed, 20 insertions(+), 58 deletions(-) diff --git a/examples/advanced/src/main.rs b/examples/advanced/src/main.rs index b762402..27d1687 100644 --- a/examples/advanced/src/main.rs +++ b/examples/advanced/src/main.rs @@ -314,12 +314,6 @@ fn main() { } }); - 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 (now on NodeEditorComputations global) === let sm_check = selection_manager.clone(); diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index ebf0c2f..cbcbdbb 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -186,8 +186,7 @@ export component MainWindow inherits Window { 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; - // Note: compute-link-path, node-rect, and pin-position are now handled via globals + // 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 { diff --git a/examples/animated-links/src/main.rs b/examples/animated-links/src/main.rs index e3aa32a..a2f9c0a 100644 --- a/examples/animated-links/src/main.rs +++ b/examples/animated-links/src/main.rs @@ -83,22 +83,6 @@ fn main() { // Wire all standard callbacks with one macro call wire_node_editor!(window, setup); - // Link preview path generation - window.on_compute_link_preview_path({ - let ctrl = setup.controller().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() - } - }); - // Animated link path generation (partial bezier based on progress) window.on_compute_animated_link_path({ let ctrl = setup.controller().clone(); diff --git a/examples/animated-links/ui/animated-links.slint b/examples/animated-links/ui/animated-links.slint index b49fb2b..b0ef60a 100644 --- a/examples/animated-links/ui/animated-links.slint +++ b/examples/animated-links/ui/animated-links.slint @@ -205,7 +205,6 @@ export component MainWindow inherits Window { callback node-drag-started <=> editor.node-drag-started; callback node-drag-ended <=> editor.node-drag-ended; 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; diff --git a/examples/pin-compatibility/src/main.rs b/examples/pin-compatibility/src/main.rs index b10ecb3..18c131a 100644 --- a/examples/pin-compatibility/src/main.rs +++ b/examples/pin-compatibility/src/main.rs @@ -230,27 +230,6 @@ 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| { - println!("compute_link_preview_path({}, {}, {}, {})", 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() - } - }); - // Link requested - validate and create window.on_link_requested({ let ctrl = setup.controller().clone(); diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index 2ec8467..eec5b65 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -399,7 +399,6 @@ export component MainWindow inherits Window { 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(); diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index b871b34..201fc22 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -125,6 +125,15 @@ export global NodeEditorComputations { /* 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; } /// Optional layout helpers for common node structures. diff --git a/node-editor.slint b/node-editor.slint index a3f1829..0c66632 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -230,9 +230,6 @@ export component NodeEditor { /// Sync selection IDs to link data (called after box selection) callback sync-selection-to-links([int]); - /// Compute link preview path - callback compute-link-preview-path(/* start-x */ length, /* start-y */ length, /* end-x */ length, /* end-y */ length) -> string; - // === Callbacks TO Application (events) === callback link-requested(/* start-pin */ int, /* end-pin */ int); callback link-cancelled(); @@ -294,15 +291,17 @@ 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 diff --git a/src/lib.rs b/src/lib.rs index 3db467a..ea51c78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,6 +116,11 @@ macro_rules! wire_node_editor { ctrl.cache().borrow().find_pin_at(x, y, radius) }); + // Wire compute-link-preview-path for link creation preview + 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() + }); + // Wire viewport_changed for automatic grid updates let ctrl = $setup.controller().clone(); let w = $window.as_weak(); diff --git a/tests/common/harness.rs b/tests/common/harness.rs index 3f3c81c..f75b969 100644 --- a/tests/common/harness.rs +++ b/tests/common/harness.rs @@ -289,10 +289,6 @@ impl MinimalTestHarness { } }); - 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 b358bc8..0c493ad 100644 --- a/tests/ui/test.slint +++ b/tests/ui/test.slint @@ -108,7 +108,6 @@ export component MainWindow inherits Window { 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() { From 75c6699888270fe13976ade18c97016841c432a1 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 17:15:37 +0000 Subject: [PATCH 17/22] zoom pin handling in lib wip wip-multi --- examples/advanced/src/main.rs | 56 ++++++---------- examples/advanced/ui/ui.slint | 8 +-- examples/minimal/ui/minimal.slint | 1 + .../ui/pin-compatibility.slint | 6 +- examples/sugiyama/ui/sugiyama.slint | 1 + node-editor-building-blocks.slint | 39 ++++++++--- node-editor.slint | 64 ++++++++++++++----- src/lib.rs | 39 +++++++++-- src/setup.rs | 23 ++++++- tests/common/harness.rs | 44 ++++++------- tests/ui/test.slint | 8 +-- 11 files changed, 186 insertions(+), 103 deletions(-) diff --git a/examples/advanced/src/main.rs b/examples/advanced/src/main.rs index 27d1687..65092fe 100644 --- a/examples/advanced/src/main.rs +++ b/examples/advanced/src/main.rs @@ -296,13 +296,6 @@ fn main() { } }); - window.on_compute_box_selection({ - let ctrl = setup.controller().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 = setup.controller().clone(); let links = links.clone(); @@ -322,25 +315,30 @@ fn main() { let lsm_check = link_selection_manager.clone(); 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(); @@ -366,35 +364,23 @@ 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 === diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index cbcbdbb..eac06cc 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -184,7 +184,6 @@ 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; // Note: compute-link-path, compute-link-preview-path, node-rect, and pin-position are now handled via globals @@ -213,11 +212,10 @@ 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 is now via NodeEditorComputations global diff --git a/examples/minimal/ui/minimal.slint b/examples/minimal/ui/minimal.slint index e93d6ac..09e966d 100644 --- a/examples/minimal/ui/minimal.slint +++ b/examples/minimal/ui/minimal.slint @@ -89,6 +89,7 @@ export component MainWindow inherits Window { world-y: data.y * 1px; viewport-width: editor.width; viewport-height: editor.height; + selected: NodeEditorComputations.is-node-selected(data.id, editor.selection-version); } } } diff --git a/examples/pin-compatibility/ui/pin-compatibility.slint b/examples/pin-compatibility/ui/pin-compatibility.slint index eec5b65..cd87a89 100644 --- a/examples/pin-compatibility/ui/pin-compatibility.slint +++ b/examples/pin-compatibility/ui/pin-compatibility.slint @@ -59,7 +59,7 @@ component ValidatedPin inherits Rectangle { in property valid-target-pin-id: 0; // Pin ID that's a valid drop target property is-valid-target: root.valid-target-pin-id == root.pin-id && root.pin-id > 0; - property pin-size: 12px * ViewportState.zoom; + property pin-size: 12px; width: pin-size; height: pin-size; @@ -69,8 +69,8 @@ component ValidatedPin inherits Rectangle { node-id: root.node-id; pin-type: root.pin-type; base-color: root.base-color; - node-screen-x: root.node-screen-x + root.x * ViewportState.zoom; - node-screen-y: root.node-screen-y + root.y * ViewportState.zoom; + 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; diff --git a/examples/sugiyama/ui/sugiyama.slint b/examples/sugiyama/ui/sugiyama.slint index 51e515f..7d73ec2 100644 --- a/examples/sugiyama/ui/sugiyama.slint +++ b/examples/sugiyama/ui/sugiyama.slint @@ -105,6 +105,7 @@ export component MainWindow inherits Window { title: data.title; world-x: data.x * 1px; world-y: data.y * 1px; + selected: NodeEditorComputations.is-node-selected(data.id, editor.selection-version); } } } diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 201fc22..2a38809 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -134,6 +134,16 @@ export global NodeEditorComputations { /* 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. @@ -474,8 +484,9 @@ export component BaseNode inherits Rectangle { // === Computed Properties === // World position (for reporting to Rust) - property world-pos-x: world-x + persistent-offset-x + drag-area.current-drag-offset-x; - property world-pos-y: world-y + persistent-offset-y + drag-area.current-drag-offset-y; + // 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; @@ -591,6 +602,11 @@ export component BaseNode inherits Rectangle { 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 { @@ -615,12 +631,18 @@ export component BaseNode inherits Rectangle { 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 { is-dragging = true; 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) / ViewportState.zoom; current-drag-offset-y = ((self.absolute-position.y + self.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); @@ -694,8 +716,9 @@ export component Pin inherits Rectangle { touch := TouchArea { moved => { if LinkCreation.state > 0 { - LinkCreation.current-x = node-screen-x + (root.x + self.mouse-x) * ViewportState.zoom; - LinkCreation.current-y = node-screen-y + (root.y + self.mouse-y) * ViewportState.zoom; + // 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; } @@ -705,15 +728,15 @@ export component Pin inherits Rectangle { // 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 + (root.x + self.x + self.width / 2) * ViewportState.zoom; - LinkCreation.current-y = node-screen-y + (root.y + self.y + self.height / 2) * ViewportState.zoom; + 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 + (root.x + self.mouse-x) * ViewportState.zoom; - LinkCreation.current-y = node-screen-y + (root.y + self.mouse-y) * ViewportState.zoom; + 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 0c66632..fcd2239 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -169,6 +169,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; @@ -215,18 +217,18 @@ export component NodeEditor { /// Compute links in selection box, 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]); + /// Notification callback when node selection changes (for application tracking) + callback node-selected(/* node-id */ int, /* shift-held */ bool); + + /// Notification callback when selection is cleared (for application tracking) + callback selection-cleared(); + /// Sync selection IDs to link data (called after box selection) callback sync-selection-to-links([int]); @@ -362,7 +364,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); } @@ -537,7 +566,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, self.mouse-y) < 0)) { // Start box selection root.internal-is-box-selecting = true; root.box-start-x = self.mouse-x; @@ -549,7 +579,7 @@ 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, self.mouse-y) >= 0 { // Clicked on a link - select it root.select-link(root.compute-link-at(self.mouse-x, self.mouse-y), event.modifiers.shift); } @@ -565,15 +595,19 @@ 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); + 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 { @@ -582,7 +616,7 @@ 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 { + 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, self.mouse-y)); } } diff --git a/src/lib.rs b/src/lib.rs index ea51c78..289d2b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,8 +84,8 @@ pub use layout::{sugiyama_layout, sugiyama_layout_from_cache, Direction, NodePos /// Wire up all NodeEditor callbacks with a single macro call. /// -/// This macro eliminates the boilerplate of wiring geometry and computation callbacks. -/// It expands in your crate where the generated Slint types are available. +/// 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 /// @@ -97,31 +97,58 @@ pub use layout::{sugiyama_layout, sugiyama_layout_from_cache, Direction, NodePos /// }); /// /// 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()); - // Wire compute-pin-at for automatic pin hit testing let ctrl = $setup.controller().clone(); computations.on_compute_pin_at(move |x, y, radius| { ctrl.cache().borrow().find_pin_at(x, y, radius) }); - // Wire compute-link-preview-path for link creation preview 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() }); - // Wire viewport_changed for automatic grid updates + 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| { @@ -131,7 +158,7 @@ macro_rules! wire_node_editor { } }); - // Generate initial grid + // Initial grid let ctrl = $setup.controller().clone(); let w = $window.as_weak(); if let Some(w) = w.upgrade() { diff --git a/src/setup.rs b/src/setup.rs index af81f74..953cce6 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -47,6 +47,7 @@ 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. @@ -54,6 +55,7 @@ use std::rc::Rc; /// 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 @@ -61,6 +63,7 @@ where { controller: Rc, dragged_node_id: Rc>, + selection: Rc>>, on_node_moved: Rc, } @@ -76,6 +79,7 @@ where 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), } } @@ -84,6 +88,11 @@ where 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 { @@ -112,12 +121,24 @@ where /// 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(); - on_moved(node_id, delta_x, delta_y); + 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); + } } } diff --git a/tests/common/harness.rs b/tests/common/harness.rs index f75b969..96e88a6 100644 --- a/tests/common/harness.rs +++ b/tests/common/harness.rs @@ -217,30 +217,31 @@ 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 } }); @@ -250,7 +251,8 @@ impl MinimalTestHarness { 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(); @@ -281,14 +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))) - } - }); - // Initialize grid window.invoke_request_grid_update(); diff --git a/tests/ui/test.slint b/tests/ui/test.slint index 0c493ad..803997b 100644 --- a/tests/ui/test.slint +++ b/tests/ui/test.slint @@ -96,17 +96,15 @@ 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; // 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; // Function to refresh links after geometry is reported From abb56fae74091a1e4de973cc9c18854b61bc6714 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 18:24:02 +0000 Subject: [PATCH 18/22] multi-select/move working --- examples/advanced/ui/ui.slint | 3 +++ node-editor-building-blocks.slint | 29 +++++++++++++++++++++++++++-- node-editor.slint | 9 ++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/examples/advanced/ui/ui.slint b/examples/advanced/ui/ui.slint index eac06cc..b44ba13 100644 --- a/examples/advanced/ui/ui.slint +++ b/examples/advanced/ui/ui.slint @@ -277,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; diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 2a38809..1d7fd38 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -66,6 +66,17 @@ export global LinkCreation { in-out property current-y: 0px; } +/// Selection request state - updated by BaseNode, consumed by NodeEditor. +/// This allows BaseNode to trigger selection without manual callback forwarding. +export global SelectionRequest { + /// Version counter - incremented on every selection request + in-out property version: 0; + /// The node ID to select + in-out property node-id: 0; + /// Whether shift key was held + 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. @@ -542,6 +553,16 @@ export component BaseNode inherits Rectangle { GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } + 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); + } + } // === Double-click detection === property dbl-click-armed: false; @@ -587,7 +608,9 @@ 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 { - GeometryCallbacks.click-node(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 @@ -610,7 +633,9 @@ export component BaseNode inherits Rectangle { } else { // Click without drag - if we deferred selection, do it now if was-selected-on-press && !shift-held { - GeometryCallbacks.click-node(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 { diff --git a/node-editor.slint b/node-editor.slint index fcd2239..17d7998 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -37,6 +37,7 @@ import { GeometryVersion, GeometryCallbacks, LinkCreation, + SelectionRequest, NodeEditorComputations, NodeStyleDefaults, MinimapNode, @@ -47,7 +48,7 @@ import { BaseNode, Pin, } from "node-editor-building-blocks.slint"; -export { PinTypes, ViewportState, DragState, GeometryVersion, GeometryCallbacks, LinkCreation, NodeEditorComputations, NodeStyleDefaults, MinimapNode, MinimapPosition, LinkData, Minimap, Link, BaseNode, Pin } +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 { @@ -484,6 +485,12 @@ export component NodeEditor { 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() { From 4a8c50b782c48bfe25721ebb02d96e53f1dd1496 Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 18:59:40 +0000 Subject: [PATCH 19/22] curves are now also selectable and deletable --- node-editor.slint | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/node-editor.slint b/node-editor.slint index 17d7998..45aba77 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -206,16 +206,16 @@ 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 link (called on click). Rust handles the selection logic. @@ -574,7 +574,7 @@ export component NodeEditor { // Modifier key can force box selection even over links // Empty area allows box selection if box-selection-on-empty is true // 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, self.mouse-y) < 0)) { + 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; @@ -586,9 +586,9 @@ export component NodeEditor { root.selected-link-ids = []; root.clear-selection(); } - } else if root.has-link-selection && 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; @@ -610,8 +610,10 @@ export component NodeEditor { 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.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; @@ -624,7 +626,7 @@ export component NodeEditor { if event.kind == PointerEventKind.move { // Link hover detection when not doing other interactions 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, self.mouse-y)); + root.update-link-hover(root.compute-link-at((self.mouse-x - root.pan-x) / root.zoom, (self.mouse-y - root.pan-y) / root.zoom)); } } } From e4e31f3ef8b2e9fa19f0ca1c243176e090390a7a Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 21:29:00 +0000 Subject: [PATCH 20/22] missing selectionRequest added --- node-editor-building-blocks.slint | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 1d7fd38..718f3ad 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -67,13 +67,13 @@ export global LinkCreation { } /// Selection request state - updated by BaseNode, consumed by NodeEditor. -/// This allows BaseNode to trigger selection without manual callback forwarding. +/// 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 + /// The node ID to select/deselect in-out property node-id: 0; - /// Whether shift key was held + /// Whether shift key was held during the click in-out property shift-held: false; } @@ -114,29 +114,29 @@ export global NodeEditorComputations { /* 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, @@ -145,14 +145,14 @@ export global NodeEditorComputations { /* 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]); } @@ -553,16 +553,6 @@ export component BaseNode inherits Rectangle { GeometryCallbacks.report-node-rect(node-id, world-pos-x, world-pos-y, node-width, node-height); } } - 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); - } - } // === Double-click detection === property dbl-click-armed: false; From c96762aac8e59c4cad64ad62472edc5751d2271b Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 21:41:51 +0000 Subject: [PATCH 21/22] again fixed connections on multidrag --- node-editor-building-blocks.slint | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index 718f3ad..a647115 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -553,6 +553,18 @@ export component BaseNode inherits Rectangle { 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); + } + } // === Double-click detection === property dbl-click-armed: false; From 1875630d48dda0166e005ba1ff8d23930417a63d Mon Sep 17 00:00:00 2001 From: Daniel Szecket Date: Wed, 25 Mar 2026 23:07:00 +0000 Subject: [PATCH 22/22] fixed screenspace drag at small zoom --- node-editor-building-blocks.slint | 29 ++++++++++++++++++++++------- node-editor.slint | 4 ++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/node-editor-building-blocks.slint b/node-editor-building-blocks.slint index a647115..823616b 100644 --- a/node-editor-building-blocks.slint +++ b/node-editor-building-blocks.slint @@ -27,6 +27,9 @@ export global ViewportState { 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. @@ -600,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; @@ -653,9 +660,15 @@ 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; GeometryCallbacks.start-node-drag(node-id, selected, world-x, world-y); // Set global drag state for multi-node drag @@ -664,8 +677,10 @@ export component BaseNode inherits Rectangle { } } if is-dragging { - current-drag-offset-x = ((self.absolute-position.x + self.mouse-x) - start-abs-x) / ViewportState.zoom; - current-drag-offset-y = ((self.absolute-position.y + self.mouse-y) - start-abs-y) / ViewportState.zoom; + // 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; diff --git a/node-editor.slint b/node-editor.slint index 45aba77..fcd8c19 100644 --- a/node-editor.slint +++ b/node-editor.slint @@ -632,6 +632,10 @@ export component NodeEditor { } 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);