From e3b35850df85846773ab86bac9cb14055f2f64f6 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Thu, 13 Apr 2023 17:31:29 +0200 Subject: [PATCH 01/22] Move from indexes to identifiers for nodes and ports --- Demo/Shared/ContentView.swift | 42 +++++++------ Flow.playground/Contents.swift | 44 ++++++++------ Sources/Flow/Model/Node+Gestures.swift | 12 ++-- Sources/Flow/Model/Node.swift | 32 +++++++++- Sources/Flow/Model/Patch+Gestures.swift | 30 +++++----- Sources/Flow/Model/Patch+Layout.swift | 39 +++++++----- Sources/Flow/Model/Patch.swift | 9 +-- Sources/Flow/Model/Port.swift | 62 +++++++++++++++----- Sources/Flow/Views/NodeEditor+Drawing.swift | 36 +++++++----- Sources/Flow/Views/NodeEditor+Gestures.swift | 61 ++++++++++--------- Sources/Flow/Views/NodeEditor+Rects.swift | 38 ++++++------ Sources/Flow/Views/NodeEditor.swift | 12 ++-- 12 files changed, 261 insertions(+), 156 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index 86532f3..346fcc8 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -2,21 +2,24 @@ import Flow import SwiftUI func simplePatch() -> Patch { - let generator = Node(name: "generator", titleBarColor: Color.cyan, outputs: ["out"]) - let processor = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) - let mixer = Node(name: "mixer", titleBarColor: Color.gray, inputs: ["in1", "in2"], outputs: ["out"]) - let output = Node(name: "output", titleBarColor: Color.purple, inputs: ["in"]) + let generator1 = Node(name: "generator", titleBarColor: Color.cyan, outputs: ["out"]) + let processor2 = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) + let generator3 = Node(name: "generator", titleBarColor: Color.cyan, outputs: ["out"]) + let processor4 = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) + let mixer5 = Node(name: "mixer", titleBarColor: Color.gray, inputs: ["in1", "in2"], outputs: ["out"]) + let output6 = Node(name: "output", titleBarColor: Color.purple, inputs: ["in"]) - let nodes = [generator, processor, generator, processor, mixer, output] + let nodes = Set([generator1, processor2, generator3, processor4, mixer5, output6]) - let wires = Set([Wire(from: OutputID(0, 0), to: InputID(1, 0)), - Wire(from: OutputID(1, 0), to: InputID(4, 0)), - Wire(from: OutputID(2, 0), to: InputID(3, 0)), - Wire(from: OutputID(3, 0), to: InputID(4, 1)), - Wire(from: OutputID(4, 0), to: InputID(5, 0))]) + let wires = Set([Wire(from: OutputID(generator1, \.[0]), to: InputID(processor2, \.[0])), + Wire(from: OutputID(processor2, \.[0]), to: InputID(mixer5, \.[0])), + Wire(from: OutputID(generator3, \.[0]), to: InputID(processor4, \.[0])), + Wire(from: OutputID(processor4, \.[0]), to: InputID(mixer5, \.[1])), + Wire(from: OutputID(mixer5, \.[0]), to: InputID(output6, \.[0])) + ]) var patch = Patch(nodes: nodes, wires: wires) - patch.recursiveLayout(nodeIndex: 5, at: CGPoint(x: 800, y: 50)) + patch.recursiveLayout(nodeId: output6.id, at: CGPoint(x: 800, y: 50)) return patch } @@ -34,23 +37,28 @@ func randomPatch() -> Patch { var randomWires: Set = [] for n in 0 ..< 50 { - randomWires.insert(Wire(from: OutputID(n, 0), to: InputID(Int.random(in: 0 ... 49), 0))) + randomWires.insert( + Wire( + from: OutputID(randomNodes[n], \.[0]), + to: InputID(randomNodes[Int.random(in: 0 ... 49)], \.[0]) + ) + ) } - return Patch(nodes: randomNodes, wires: randomWires) + return Patch(nodes: Set(randomNodes), wires: randomWires) } struct ContentView: View { - @State var patch = simplePatch() - @State var selection = Set() + @StateObject var patch = simplePatch() + @State var selection = Set() func addNode() { let newNode = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) - patch.nodes.append(newNode) + patch.nodes.insert(newNode) } var body: some View { ZStack(alignment: .topTrailing) { - NodeEditor(patch: $patch, selection: $selection) + NodeEditor(patch: patch, selection: $selection) Button("Add Node", action: addNode).padding() } } diff --git a/Flow.playground/Contents.swift b/Flow.playground/Contents.swift index c52cd0d..8740845 100644 --- a/Flow.playground/Contents.swift +++ b/Flow.playground/Contents.swift @@ -3,44 +3,52 @@ import PlaygroundSupport import SwiftUI func simplePatch() -> Patch { - let midiSource = Node(name: "MIDI source", + let midiSource1 = Node(name: "MIDI source", outputs: [ Port(name: "out ch. 1", type: .midi), Port(name: "out ch. 2", type: .midi), ]) - let generator = Node(name: "generator", + let generator2 = Node(name: "generator", inputs: [ Port(name: "midi in", type: .midi), Port(name: "CV in", type: .control), ], outputs: [Port(name: "out")]) - let processor = Node(name: "processor", inputs: ["in"], outputs: ["out"]) - let mixer = Node(name: "mixer", inputs: ["in1", "in2"], outputs: ["out"]) - let output = Node(name: "output", inputs: ["in"]) + let processor3 = Node(name: "processor 3", inputs: ["in"], outputs: ["out"]) + let generator4 = Node(name: "generator", + inputs: [ + Port(name: "midi in", type: .midi), + Port(name: "CV in", type: .control), + ], + outputs: [Port(name: "out")]) + let processor5 = Node(name: "processor", inputs: ["in"], outputs: ["out"]) + let mixer6 = Node(name: "mixer", inputs: ["in1", "in2"], outputs: ["out"]) + let output7 = Node(name: "output", inputs: ["in"]) - let nodes = [midiSource, generator, processor, generator, processor, mixer, output] + let nodes = [midiSource1, generator2, processor3, generator4, processor5, mixer6, output7] let wires = Set([ - Wire(from: OutputID(0, 0), to: InputID(1, 0)), - Wire(from: OutputID(0, 1), to: InputID(3, 0)), - Wire(from: OutputID(1, 0), to: InputID(2, 0)), - Wire(from: OutputID(2, 0), to: InputID(5, 0)), - Wire(from: OutputID(3, 0), to: InputID(4, 0)), - Wire(from: OutputID(4, 0), to: InputID(5, 1)), - Wire(from: OutputID(5, 0), to: InputID(6, 0)), + Wire(from: OutputID(midiSource1, \.[0]), to: InputID(generator2, \.[0])), + Wire(from: OutputID(midiSource1, \.[1]), to: InputID(generator4, \.[0])), + Wire(from: OutputID(generator2, \.[0]), to: InputID(processor3, \.[0])), + Wire(from: OutputID(processor3, \.[0]), to: InputID(mixer6, \.[0])), + Wire(from: OutputID(generator4, \.[0]), to: InputID(processor5, \.[0])), + Wire(from: OutputID(generator4, \.[0]), to: InputID(processor5, \.[0])), + Wire(from: OutputID(processor5, \.[0]), to: InputID(mixer6, \.[1])), + Wire(from: OutputID(mixer6, \.[0]), to: InputID(output7, \.[0])) ]) - var patch = Patch(nodes: nodes, wires: wires) - patch.recursiveLayout(nodeIndex: 6, at: CGPoint(x: 1000, y: 50)) + var patch = Patch(nodes: Set(nodes), wires: wires) + patch.recursiveLayout(nodeId: output7.id, at: CGPoint(x: 1000, y: 50)) return patch } struct FlowDemoView: View { - @State var patch = simplePatch() - @State var selection = Set() + @StateObject var patch = simplePatch() + @State var selection = Set() public var body: some View { - NodeEditor(patch: $patch, selection: $selection) + NodeEditor(patch: patch, selection: $selection) .nodeColor(.secondary) .portColor(for: .control, .gray) .portColor(for: .signal, Gradient(colors: [.yellow, .blue])) diff --git a/Sources/Flow/Model/Node+Gestures.swift b/Sources/Flow/Model/Node+Gestures.swift index eef5ce8..9117fb0 100644 --- a/Sources/Flow/Model/Node+Gestures.swift +++ b/Sources/Flow/Model/Node+Gestures.swift @@ -11,20 +11,20 @@ extension Node { return result } - func hitTest(nodeIndex: Int, point: CGPoint, layout: LayoutConstants) -> Patch.HitTestResult? { - for (inputIndex, _) in inputs.enumerated() { + func hitTest(nodeId: NodeId, point: CGPoint, layout: LayoutConstants) -> Patch.HitTestResult? { + for (inputIndex, input) in inputs.enumerated() { if inputRect(input: inputIndex, layout: layout).contains(point) { - return .input(nodeIndex, inputIndex) + return .input(nodeId, input.id) } } - for (outputIndex, _) in outputs.enumerated() { + for (outputIndex, output) in outputs.enumerated() { if outputRect(output: outputIndex, layout: layout).contains(point) { - return .output(nodeIndex, outputIndex) + return .output(nodeId, output.id) } } if rect(layout: layout).contains(point) { - return .node(nodeIndex) + return .node(nodeId) } return nil diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index 4d42b5d..0854e69 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -5,13 +5,15 @@ import SwiftUI /// Nodes are identified by index in `Patch/nodes``. public typealias NodeIndex = Int +public typealias NodeId = UUID /// Nodes are identified by index in ``Patch/nodes``. /// /// Using indices as IDs has proven to be easy and fast for our use cases. The ``Patch`` should be /// generated from your own data model, not used as your data model, so there isn't a requirement that /// the indices be consistent across your editing operations (such as deleting nodes). -public struct Node: Equatable { +public struct Node: Hashable, Equatable { + public let id: NodeId = UUID() public var name: String public var position: CGPoint public var titleBarColor: Color @@ -52,4 +54,32 @@ public struct Node: Equatable { self.inputs = inputs.map { Port(name: $0) } self.outputs = outputs.map { Port(name: $0) } } + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + func indexOfOutput(_ port: OutputID) -> Array.Index? { + self.outputs.firstIndex { $0.id == port.portId } + } + + func indexOfInput(_ port: InputID) -> Array.Index? { + self.inputs.firstIndex { $0.id == port.portId } + } +} + +extension Sequence where Element == Node { + subscript(withId id: NodeId) -> Node { + get { + guard let node = first(where: { $0.id == id }) else { + fatalError("Node with identifier \(id.uuidString) not found") + } + + return node + } + } } diff --git a/Sources/Flow/Model/Patch+Gestures.swift b/Sources/Flow/Model/Patch+Gestures.swift index 1394727..f43b609 100644 --- a/Sources/Flow/Model/Patch+Gestures.swift +++ b/Sources/Flow/Model/Patch+Gestures.swift @@ -5,15 +5,15 @@ import Foundation extension Patch { enum HitTestResult { - case node(NodeIndex) - case input(NodeIndex, PortIndex) - case output(NodeIndex, PortIndex) + case node(NodeId) + case input(NodeId, PortId) + case output(NodeId, PortId) } /// Hit test a point against the whole patch. func hitTest(point: CGPoint, layout: LayoutConstants) -> HitTestResult? { - for (nodeIndex, node) in nodes.enumerated().reversed() { - if let result = node.hitTest(nodeIndex: nodeIndex, point: point, layout: layout) { + for node in nodes.reversed() { + if let result = node.hitTest(nodeId: node.id, point: point, layout: layout) { return result } } @@ -21,23 +21,25 @@ extension Patch { return nil } - mutating func moveNode( - nodeIndex: NodeIndex, + func moveNode( + nodeId: NodeId, offset: CGSize, nodeMoved: NodeEditor.NodeMovedHandler ) { - if !nodes[nodeIndex].locked { - nodes[nodeIndex].position += offset - nodeMoved(nodeIndex, nodes[nodeIndex].position) + var node = nodes[withId: nodeId] + if !node.locked { + node.position += offset + nodes.update(with: node) + nodeMoved(nodeId, node.position) } } - func selected(in rect: CGRect, layout: LayoutConstants) -> Set { - var selection = Set() + func selected(in rect: CGRect, layout: LayoutConstants) -> Set { + var selection = Set() - for (idx, node) in nodes.enumerated() { + for node in nodes { if rect.intersects(node.rect(layout: layout)) { - selection.insert(idx) + selection.insert(node.id) } } return selection diff --git a/Sources/Flow/Model/Patch+Layout.swift b/Sources/Flow/Model/Patch+Layout.swift index e640977..4848c5f 100644 --- a/Sources/Flow/Model/Patch+Layout.swift +++ b/Sources/Flow/Model/Patch+Layout.swift @@ -8,30 +8,37 @@ public extension Patch { /// /// - Returns: Height of all nodes in subtree. @discardableResult - mutating func recursiveLayout( - nodeIndex: NodeIndex, + func recursiveLayout( + nodeId: NodeId, at point: CGPoint, layout: LayoutConstants = LayoutConstants(), - consumedNodeIndexes: Set = [], + consumedNodeIndexes: Set = [], nodePadding: Bool = false ) -> (aggregateHeight: CGFloat, - consumedNodeIndexes: Set) + consumedNodeIndexes: Set) { - nodes[nodeIndex].position = point + var node = nodes[withId: nodeId] + node.position = point + nodes.update(with: node) // XXX: super slow let incomingWires = wires.filter { - $0.input.nodeIndex == nodeIndex - }.sorted(by: { $0.input.portIndex < $1.input.portIndex }) + $0.input.nodeId == nodeId + }.sorted(by: { lhs, rhs in + guard let lhsIndex = node.indexOfInput(lhs.input), + let rhsIndex = node.indexOfInput(rhs.input) + else { return false } + return lhsIndex < rhsIndex + }) var consumedNodeIndexes = consumedNodeIndexes var height: CGFloat = 0 for wire in incomingWires { let addPadding = wire == incomingWires.last - let ni = wire.output.nodeIndex + let ni = wire.output.nodeId guard !consumedNodeIndexes.contains(ni) else { continue } - let rl = recursiveLayout(nodeIndex: ni, + let rl = recursiveLayout(nodeId: ni, at: CGPoint(x: point.x - layout.nodeWidth - layout.nodeSpacing, y: point.y + height), layout: layout, @@ -42,7 +49,7 @@ public extension Patch { consumedNodeIndexes.formUnion(rl.consumedNodeIndexes) } - let nodeHeight = nodes[nodeIndex].rect(layout: layout).height + let nodeHeight = node.rect(layout: layout).height let aggregateHeight = max(height, nodeHeight) + (nodePadding ? layout.nodeSpacing : 0) return (aggregateHeight: aggregateHeight, consumedNodeIndexes: consumedNodeIndexes) @@ -54,8 +61,8 @@ public extension Patch { /// - origin: Top-left origin coordinate. /// - columns: Array of columns each comprised of an array of node indexes. /// - layout: Layout constants. - mutating func stackedLayout(at origin: CGPoint = .zero, - _ columns: [[NodeIndex]], + func stackedLayout(at origin: CGPoint = .zero, + _ columns: [[NodeId]], layout: LayoutConstants = LayoutConstants()) { for column in columns.indices { @@ -63,13 +70,15 @@ public extension Patch { var yOffset: CGFloat = 0 let xPos = origin.x + (CGFloat(column) * (layout.nodeWidth + layout.nodeSpacing)) - for nodeIndex in nodeStack { - nodes[nodeIndex].position = .init( + for nodeId in nodeStack { + var node = nodes[withId: nodeId] + node.position = .init( x: xPos, y: origin.y + yOffset ) + nodes.update(with: node) - let nodeHeight = nodes[nodeIndex].rect(layout: layout).height + let nodeHeight = nodes[withId: nodeId].rect(layout: layout).height yOffset += nodeHeight if column != columns.indices.last { yOffset += layout.nodeSpacing diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index a10be9b..4b9a8ee 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -2,17 +2,18 @@ import CoreGraphics import Foundation +import SwiftUI /// Data model for Flow. /// /// Write a function to generate a `Patch` from your own data model /// as well as a function to update your data model when the `Patch` changes. /// Use SwiftUI's `onChange(of:)` to monitor changes, or use `NodeEditor.onNodeAdded`, etc. -public struct Patch: Equatable { - public var nodes: [Node] - public var wires: Set +public class Patch: ObservableObject { + @Published public var nodes: Set + @Published public var wires: Set - public init(nodes: [Node], wires: Set) { + public init(nodes: Set, wires: Set) { self.nodes = nodes self.wires = wires } diff --git a/Sources/Flow/Model/Port.swift b/Sources/Flow/Model/Port.swift index 528681f..8c4ef11 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -4,34 +4,53 @@ import Foundation /// Ports are identified by index within a node. public typealias PortIndex = Int +public typealias PortId = UUID /// Uniquely identifies an input by indices. public struct InputID: Equatable, Hashable { - public let nodeIndex: NodeIndex - public let portIndex: PortIndex + public let nodeId: NodeId + public var portId: PortId + /// Initialize an output + /// - Parameters: + /// - node: The node the input belongs + /// - portKeyPath: The keypath to access the Port on Node's input + public init(_ node: Node, _ portKeyPath: KeyPath<[Port], Port>) { + self.nodeId = node.id + self.portId = node.inputs[keyPath: portKeyPath].id + } + /// Initialize an input /// - Parameters: - /// - nodeIndex: Index for the node the input belongs - /// - portIndex: Index to the input within the node - public init(_ nodeIndex: NodeIndex, _ portIndex: PortIndex) { - self.nodeIndex = nodeIndex - self.portIndex = portIndex + /// - nodeId: The id of the node the input belongs to + /// - portId: The id of the input port + public init(_ nodeId: NodeId, _ portId: PortId) { + self.nodeId = nodeId + self.portId = portId } } /// Uniquely identifies an output by indices. public struct OutputID: Equatable, Hashable { - public let nodeIndex: NodeIndex - public let portIndex: PortIndex + public let nodeId: NodeId + public var portId: PortId /// Initialize an output /// - Parameters: - /// - nodeIndex: Index for the node the output belongs - /// - portIndex: Index to the output within the node - public init(_ nodeIndex: NodeIndex, _ portIndex: PortIndex) { - self.nodeIndex = nodeIndex - self.portIndex = portIndex + /// - node: The node the output belongs + /// - portKeyPath: The keypath to access the Port on Node's outputs + public init(_ node: Node, _ portKeyPath: KeyPath<[Port], Port>) { + self.nodeId = node.id + self.portId = node.outputs[keyPath: portKeyPath].id + } + + /// Initialize an output + /// - Parameters: + /// - nodeId: The id of the node the output belongs to + /// - portId: The id of the output port + public init(_ nodeId: NodeId, _ portId: PortId) { + self.nodeId = nodeId + self.portId = portId } } @@ -48,7 +67,8 @@ public enum PortType: Equatable, Hashable { } /// Information for either an input or an output. -public struct Port: Equatable, Hashable { +public struct Port: Equatable, Hashable, Identifiable { + public let id: PortId = UUID() public let name: String public let type: PortType @@ -61,3 +81,15 @@ public struct Port: Equatable, Hashable { self.type = type } } + +extension Sequence where Element == Port { + subscript(id: PortId) -> Port { + get { + guard let port = first(where: { $0.id == id }) else { + fatalError("Port with identifier \(id.uuidString) not found") + } + + return port + } + } +} diff --git a/Sources/Flow/Views/NodeEditor+Drawing.swift b/Sources/Flow/Views/NodeEditor+Drawing.swift index 5642043..3576a71 100644 --- a/Sources/Flow/Views/NodeEditor+Drawing.swift +++ b/Sources/Flow/Views/NodeEditor+Drawing.swift @@ -122,8 +122,8 @@ extension NodeEditor { var resolvedInputColors = [PortType: GraphicsContext.Shading]() var resolvedOutputColors = [PortType: GraphicsContext.Shading]() - for (nodeIndex, node) in patch.nodes.enumerated() { - let offset = self.offset(for: nodeIndex) + for node in patch.nodes { + let offset = self.offset(for: node.id) let rect = node.rect(layout: layout).offset(by: offset) guard rect.intersects(viewport) else { continue } @@ -138,7 +138,7 @@ extension NodeEditor { case let .selection(rect: selectionRect): selected = rect.intersects(selectionRect) default: - selected = selection.contains(nodeIndex) + selected = selection.contains(node.id) } cx.fill(bg, with: selected ? selectedShading : unselectedShading) @@ -178,7 +178,7 @@ extension NodeEditor { index: i, offset: offset, portShading: inputShading(input.type, &resolvedInputColors, cx), - isConnected: connectedInputs.contains(InputID(nodeIndex, i)) + isConnected: connectedInputs.contains(InputID(node, \.[i])) ) } @@ -189,7 +189,7 @@ extension NodeEditor { index: i, offset: offset, portShading: outputShading(output.type, &resolvedOutputColors, cx), - isConnected: connectedOutputs.contains(OutputID(nodeIndex, i)) + isConnected: connectedOutputs.contains(OutputID(node, \.[i])) ) } } @@ -204,17 +204,21 @@ extension NodeEditor { hideWire = nil } for wire in patch.wires where wire != hideWire { - let fromPoint = self.patch.nodes[wire.output.nodeIndex].outputRect( - output: wire.output.portIndex, + let fromNode = self.patch.nodes[withId: wire.output.nodeId] + guard let portIndexInFromNode = fromNode.indexOfOutput(wire.output) else { continue } + let fromPoint = fromNode.outputRect( + output: portIndexInFromNode, layout: self.layout ) - .offset(by: self.offset(for: wire.output.nodeIndex)).center + .offset(by: self.offset(for: wire.output.nodeId)).center - let toPoint = self.patch.nodes[wire.input.nodeIndex].inputRect( - input: wire.input.portIndex, + let toNode = self.patch.nodes[withId: wire.input.nodeId] + guard let portIndexInToNode = toNode.indexOfInput(wire.input) else { continue } + let toPoint = toNode.inputRect( + input: portIndexInToNode, layout: self.layout ) - .offset(by: self.offset(for: wire.input.nodeIndex)).center + .offset(by: self.offset(for: wire.input.nodeId)).center let bounds = CGRect(origin: fromPoint, size: toPoint - fromPoint) if viewport.intersects(bounds) { @@ -226,9 +230,9 @@ extension NodeEditor { func drawDraggedWire(cx: GraphicsContext) { if case let .wire(output: output, offset: offset, _) = dragInfo { - let outputRect = self.patch - .nodes[output.nodeIndex] - .outputRect(output: output.portIndex, layout: self.layout) + let fromNode = self.patch.nodes[withId: output.nodeId] + guard let portIndexInFromNode = fromNode.indexOfOutput(output) else { return } + let outputRect = fromNode.outputRect(output: portIndexInFromNode, layout: self.layout) let gradient = self.gradient(for: output) cx.strokeWire(from: outputRect.center, to: outputRect.center + offset, gradient: gradient) } @@ -243,8 +247,8 @@ extension NodeEditor { func gradient(for outputID: OutputID) -> Gradient { let portType = patch - .nodes[outputID.nodeIndex] - .outputs[outputID.portIndex] + .nodes[withId: outputID.nodeId] + .outputs[outputID.portId] .type return style.gradient(for: portType) ?? .init(colors: [.gray]) } diff --git a/Sources/Flow/Views/NodeEditor+Gestures.swift b/Sources/Flow/Views/NodeEditor+Gestures.swift index c6f65ea..466864e 100644 --- a/Sources/Flow/Views/NodeEditor+Gestures.swift +++ b/Sources/Flow/Views/NodeEditor+Gestures.swift @@ -6,7 +6,7 @@ extension NodeEditor { /// State for all gestures. enum DragInfo { case wire(output: OutputID, offset: CGSize = .zero, hideWire: Wire? = nil) - case node(index: NodeIndex, offset: CGSize = .zero) + case node(id: NodeId, offset: CGSize = .zero) case selection(rect: CGRect = .zero) case none } @@ -74,20 +74,27 @@ extension NodeEditor { case .none: dragInfo = .selection(rect: CGRect(a: startLocation, b: location)) - case let .node(nodeIndex): - dragInfo = .node(index: nodeIndex, offset: translation) - case let .output(nodeIndex, portIndex): - dragInfo = DragInfo.wire(output: OutputID(nodeIndex, portIndex), offset: translation) - case let .input(nodeIndex, portIndex): - let node = patch.nodes[nodeIndex] + case let .node(nodeId): + dragInfo = .node(id: nodeId, offset: translation) + case let .output(nodeId, portId): + dragInfo = DragInfo.wire(output: OutputID(nodeId, portId), offset: translation) + case let .input(nodeId, portId): // Is a wire attached to the input? - if let attachedWire = attachedWire(inputID: InputID(nodeIndex, portIndex)) { - let offset = node.inputRect(input: portIndex, layout: layout).center - - patch.nodes[attachedWire.output.nodeIndex].outputRect( - output: attachedWire.output.portIndex, - layout: layout - ).center - + translation + let inputId = InputID(nodeId, portId) + if let attachedWire = attachedWire(inputID: inputId) { + + let inputNode = patch.nodes[withId: nodeId] + let inputPortIndex = inputNode.indexOfInput(inputId) + + + let outputNode = patch.nodes[withId: attachedWire.output.nodeId] + let outputIndex = outputNode.indexOfOutput(attachedWire.output) + + guard let outputIndex, let inputPortIndex else { return } + let inputCenter = inputNode.inputRect(input: inputPortIndex, layout: layout).center + let outputCenter = outputNode.outputRect(output: outputIndex, layout: layout).center + + let offset = inputCenter - outputCenter + translation dragInfo = .wire(output: attachedWire.output, offset: offset, hideWire: attachedWire) @@ -111,30 +118,30 @@ extension NodeEditor { in: selectionRect, layout: layout ) - case let .node(nodeIndex): + case let .node(nodeId): patch.moveNode( - nodeIndex: nodeIndex, + nodeId: nodeId, offset: translation, nodeMoved: self.nodeMoved ) - if selection.contains(nodeIndex) { - for idx in selection where idx != nodeIndex { + if selection.contains(nodeId) { + for idx in selection where idx != nodeId { patch.moveNode( - nodeIndex: idx, + nodeId: idx, offset: translation, nodeMoved: self.nodeMoved ) } } - case let .output(nodeIndex, portIndex): - let type = patch.nodes[nodeIndex].outputs[portIndex].type + case let .output(nodeId, portId): + let type = patch.nodes[withId: nodeId].outputs[portId].type if let input = findInput(point: location, type: type) { - connect(OutputID(nodeIndex, portIndex), to: input) + connect(OutputID(nodeId, portId), to: input) } - case let .input(nodeIndex, portIndex): - let type = patch.nodes[nodeIndex].inputs[portIndex].type + case let .input(nodeId, portId): + let type = patch.nodes[withId: nodeId].inputs[portId].type // Is a wire attached to the input? - if let attachedWire = attachedWire(inputID: InputID(nodeIndex, portIndex)) { + if let attachedWire = attachedWire(inputID: InputID(nodeId, portId)) { patch.wires.remove(attachedWire) wireRemoved(attachedWire) if let input = findInput(point: location, type: type) { @@ -146,9 +153,9 @@ extension NodeEditor { // If we haven't moved far, then this is effectively a tap. switch hitResult { case .none: - selection = Set() + selection = Set() case let .node(nodeIndex): - selection = Set([nodeIndex]) + selection = Set([nodeIndex]) default: break } } diff --git a/Sources/Flow/Views/NodeEditor+Rects.swift b/Sources/Flow/Views/NodeEditor+Rects.swift index 755352d..246c7dc 100644 --- a/Sources/Flow/Views/NodeEditor+Rects.swift +++ b/Sources/Flow/Views/NodeEditor+Rects.swift @@ -4,16 +4,16 @@ import SwiftUI public extension NodeEditor { /// Offset to apply to a node based on selection and gesture state. - func offset(for idx: NodeIndex) -> CGSize { - if patch.nodes[idx].locked { + func offset(for nodeId: NodeId) -> CGSize { + if patch.nodes[withId: nodeId].locked { return .zero } switch dragInfo { - case let .node(index: index, offset: offset): - if idx == index { + case let .node(id: id, offset: offset): + if nodeId == id { return offset } - if selection.contains(index), selection.contains(idx) { + if selection.contains(id), selection.contains(nodeId) { // Offset other selected node only if we're dragging the // selection. return offset @@ -25,36 +25,40 @@ public extension NodeEditor { } /// Search for inputs. - func findInput(node: Node, point: CGPoint, type: PortType) -> PortIndex? { - node.inputs.enumerated().first { portIndex, input in + func findInput(node: Node, point: CGPoint, type: PortType) -> PortId? { + let inputPort = node.inputs.enumerated().first { portIndex, input in input.type == type && node.inputRect(input: portIndex, layout: layout).contains(point) - }?.0 + }?.element + + return inputPort?.id } /// Search for an input in the whole patch. func findInput(point: CGPoint, type: PortType) -> InputID? { // Search nodes in reverse to find nodes drawn on top first. - for (nodeIndex, node) in patch.nodes.enumerated().reversed() { - if let portIndex = findInput(node: node, point: point, type: type) { - return InputID(nodeIndex, portIndex) + for node in patch.nodes.reversed() { + if let portId = findInput(node: node, point: point, type: type) { + return InputID(node.id, portId) } } return nil } /// Search for outputs. - func findOutput(node: Node, point: CGPoint) -> PortIndex? { - node.outputs.enumerated().first { portIndex, _ in + func findOutput(node: Node, point: CGPoint) -> PortId? { + let outputPort = node.outputs.enumerated().first { portIndex, _ in node.outputRect(output: portIndex, layout: layout).contains(point) - }?.0 + }?.element + + return outputPort?.id } /// Search for an output in the whole patch. func findOutput(point: CGPoint) -> OutputID? { // Search nodes in reverse to find nodes drawn on top first. - for (nodeIndex, node) in patch.nodes.enumerated().reversed() { - if let portIndex = findOutput(node: node, point: point) { - return OutputID(nodeIndex, portIndex) + for node in patch.nodes.reversed() { + if let portId = findOutput(node: node, point: point) { + return OutputID(node.id, portId) } } return nil diff --git a/Sources/Flow/Views/NodeEditor.swift b/Sources/Flow/Views/NodeEditor.swift index cbfbc24..5b073e7 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -8,10 +8,10 @@ import SwiftUI /// using a View for each Node. public struct NodeEditor: View { /// Data model. - @Binding var patch: Patch + @ObservedObject var patch: Patch /// Selected nodes. - @Binding var selection: Set + @Binding var selection: Set /// State for all gestures. @GestureState var dragInfo = DragInfo.none @@ -20,7 +20,7 @@ public struct NodeEditor: View { @StateObject var textCache = TextCache() /// Node moved handler closure. - public typealias NodeMovedHandler = (_ index: NodeIndex, + public typealias NodeMovedHandler = (_ index: NodeId, _ location: CGPoint) -> Void /// Called when a node is moved. @@ -51,11 +51,11 @@ public struct NodeEditor: View { /// - Parameters: /// - patch: Patch to display. /// - selection: Set of nodes currently selected. - public init(patch: Binding, - selection: Binding>, + public init(patch: Patch, + selection: Binding>, layout: LayoutConstants = LayoutConstants()) { - _patch = patch + self.patch = patch _selection = selection self.layout = layout } From ccf479db79daeef206054b4657fb7494e89c2cc9 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Tue, 18 Apr 2023 08:42:29 +0200 Subject: [PATCH 02/22] Initial support for data flow --- Demo/Shared/ContentView.swift | 96 ++++++++++---- Flow.playground/Contents.swift | 92 ++++++++----- Sources/Flow/Model/Node+Gestures.swift | 17 ++- Sources/Flow/Model/Node+Layout.swift | 3 + Sources/Flow/Model/Node.swift | 131 +++++++++++++------ Sources/Flow/Model/Patch+Gestures.swift | 11 +- Sources/Flow/Model/Patch+Layout.swift | 8 +- Sources/Flow/Model/Patch.swift | 4 +- Sources/Flow/Model/Port.swift | 81 ++++++++++-- Sources/Flow/Views/NodeEditor+Drawing.swift | 7 +- Sources/Flow/Views/NodeEditor+Gestures.swift | 33 +++-- Sources/Flow/Views/NodeEditor+Rects.swift | 4 +- 12 files changed, 338 insertions(+), 149 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index 346fcc8..bee4152 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -1,38 +1,81 @@ import Flow import SwiftUI -func simplePatch() -> Patch { - let generator1 = Node(name: "generator", titleBarColor: Color.cyan, outputs: ["out"]) - let processor2 = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) - let generator3 = Node(name: "generator", titleBarColor: Color.cyan, outputs: ["out"]) - let processor4 = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) - let mixer5 = Node(name: "mixer", titleBarColor: Color.gray, inputs: ["in1", "in2"], outputs: ["out"]) - let output6 = Node(name: "output", titleBarColor: Color.purple, inputs: ["in"]) +class IntNode: Node { + var id: NodeId = UUID() + + var name: String + + var position: CGPoint? - let nodes = Set([generator1, processor2, generator3, processor4, mixer5, output6]) + var titleBarColor: Color = .brown - let wires = Set([Wire(from: OutputID(generator1, \.[0]), to: InputID(processor2, \.[0])), - Wire(from: OutputID(processor2, \.[0]), to: InputID(mixer5, \.[0])), - Wire(from: OutputID(generator3, \.[0]), to: InputID(processor4, \.[0])), - Wire(from: OutputID(processor4, \.[0]), to: InputID(mixer5, \.[1])), - Wire(from: OutputID(mixer5, \.[0]), to: InputID(output6, \.[0])) + var locked: Bool = false + + var inputs: PortsContainer = PortsContainer([ + Port(name: "Value", valueType: Int.self) + ]) + + var outputs: PortsContainer = PortsContainer([ + Port(name: "Value", valueType: Int.self) ]) - var patch = Patch(nodes: nodes, wires: wires) - patch.recursiveLayout(nodeId: output6.id, at: CGPoint(x: 800, y: 50)) + @Published var value: Int? = nil + + @State var valueState: Int? = nil + + var valueBinding: Binding { + Binding( + get: { self.value?.description ?? "" }, + set: { newValue in + self.value = Int.init(newValue) + } + ) + } + + var middleView: (some View)? { + HStack { + Text("The connected value is \(value?.description ?? "")") + TextField("Integer", text: valueBinding) + } + } + + init(name: String, position: CGPoint? = nil) { + self.name = name + self.position = position + + if let intInput = inputs[0] as? Flow.Port { + intInput.$value.assign(to: &$value) + } + + if let intOutput = outputs[0] as? Flow.Port { + $value.assign(to: &intOutput.$value) + } + } +} + +func simplePatch() -> Patch { + let int1 = IntNode(name: "Integer 1") + let int2 = IntNode(name: "Integer 2") + + let nodes: [any Node] = [int1, int2] + + let wires = Set([ + Wire(from: OutputID(int1, \.[0]), to: InputID(int2, \.[0])) + ]) + + let patch = Patch(nodes: nodes.asAnyNodeSet, wires: wires) + patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 800, y: 50)) return patch } /// Bit of a stress test to show how Flow performs with more nodes. func randomPatch() -> Patch { - var randomNodes: [Node] = [] + var randomNodes: [any Node] = [] for n in 0 ..< 50 { let randomPoint = CGPoint(x: 1000 * Double.random(in: 0 ... 1), y: 1000 * Double.random(in: 0 ... 1)) - randomNodes.append(Node(name: "node\(n)", - position: randomPoint, - inputs: ["In"], - outputs: ["Out"])) + randomNodes.append(IntNode(name: "Integer \(n)", position: randomPoint)) } var randomWires: Set = [] @@ -44,7 +87,7 @@ func randomPatch() -> Patch { ) ) } - return Patch(nodes: Set(randomNodes), wires: randomWires) + return Patch(nodes: randomNodes.asAnyNodeSet, wires: randomWires) } struct ContentView: View { @@ -52,14 +95,21 @@ struct ContentView: View { @State var selection = Set() func addNode() { - let newNode = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) - patch.nodes.insert(newNode) + let newNode = IntNode(name: "Integer") + patch.nodes.insert(AnyNode(newNode)) } var body: some View { ZStack(alignment: .topTrailing) { NodeEditor(patch: patch, selection: $selection) + .onWireAdded { wire in + print("Added wire: \(wire)") + } + .onWireRemoved { wire in + print("Removed wire: \(wire)") + } Button("Add Node", action: addNode).padding() } } + } diff --git a/Flow.playground/Contents.swift b/Flow.playground/Contents.swift index 8740845..8d3f63f 100644 --- a/Flow.playground/Contents.swift +++ b/Flow.playground/Contents.swift @@ -2,44 +2,68 @@ import Flow import PlaygroundSupport import SwiftUI -func simplePatch() -> Patch { - let midiSource1 = Node(name: "MIDI source", - outputs: [ - Port(name: "out ch. 1", type: .midi), - Port(name: "out ch. 2", type: .midi), - ]) - let generator2 = Node(name: "generator", - inputs: [ - Port(name: "midi in", type: .midi), - Port(name: "CV in", type: .control), - ], - outputs: [Port(name: "out")]) - let processor3 = Node(name: "processor 3", inputs: ["in"], outputs: ["out"]) - let generator4 = Node(name: "generator", - inputs: [ - Port(name: "midi in", type: .midi), - Port(name: "CV in", type: .control), - ], - outputs: [Port(name: "out")]) - let processor5 = Node(name: "processor", inputs: ["in"], outputs: ["out"]) - let mixer6 = Node(name: "mixer", inputs: ["in1", "in2"], outputs: ["out"]) - let output7 = Node(name: "output", inputs: ["in"]) +class IntNode: Node { + var id: NodeId = UUID() - let nodes = [midiSource1, generator2, processor3, generator4, processor5, mixer6, output7] + var name: String + var position: CGPoint? + var titleBarColor: Color = .brown + var locked: Bool = false - let wires = Set([ - Wire(from: OutputID(midiSource1, \.[0]), to: InputID(generator2, \.[0])), - Wire(from: OutputID(midiSource1, \.[1]), to: InputID(generator4, \.[0])), - Wire(from: OutputID(generator2, \.[0]), to: InputID(processor3, \.[0])), - Wire(from: OutputID(processor3, \.[0]), to: InputID(mixer6, \.[0])), - Wire(from: OutputID(generator4, \.[0]), to: InputID(processor5, \.[0])), - Wire(from: OutputID(generator4, \.[0]), to: InputID(processor5, \.[0])), - Wire(from: OutputID(processor5, \.[0]), to: InputID(mixer6, \.[1])), - Wire(from: OutputID(mixer6, \.[0]), to: InputID(output7, \.[0])) + var inputs: PortsContainer = PortsContainer([ + Port(name: "Value", valueType: Int.self) + ]) + + var outputs: PortsContainer = PortsContainer([ + Port(name: "Value", valueType: Int.self) ]) - var patch = Patch(nodes: Set(nodes), wires: wires) - patch.recursiveLayout(nodeId: output7.id, at: CGPoint(x: 1000, y: 50)) + @Published var value: Int? = nil + + @State var valueState: Int? = nil + + var valueBinding: Binding { + Binding( + get: { self.value?.description ?? "" }, + set: { newValue in + self.value = Int.init(newValue) + } + ) + } + + var middleView: (some View)? { + HStack { + Text("The connected value is \(value?.description ?? "")") + TextField("Integer", text: valueBinding) + } + } + + init(name: String, position: CGPoint? = nil) { + self.name = name + self.position = position + + if let intInput = inputs[0] as? Flow.Port { + intInput.$value.assign(to: &$value) + } + + if let intOutput = outputs[0] as? Flow.Port { + $value.assign(to: &intOutput.$value) + } + } +} + +func simplePatch() -> Patch { + let int1 = IntNode(name: "Integer 1") + let int2 = IntNode(name: "Integer 2") + + let nodes: [any Node] = [int1, int2] + + let wires = Set([ + Wire(from: OutputID(int1, \.[0]), to: InputID(int2, \.[0])) + ]) + + let patch = Patch(nodes: nodes.asAnyNodeSet, wires: wires) + patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 800, y: 50)) return patch } diff --git a/Sources/Flow/Model/Node+Gestures.swift b/Sources/Flow/Model/Node+Gestures.swift index 9117fb0..7f982fb 100644 --- a/Sources/Flow/Model/Node+Gestures.swift +++ b/Sources/Flow/Model/Node+Gestures.swift @@ -4,22 +4,25 @@ import CoreGraphics import Foundation extension Node { - public func translate(by offset: CGSize) -> Node { - var result = self - result.position.x += offset.width - result.position.y += offset.height - return result + public func translate(by offset: CGSize) -> CGPoint { + var position = self.position ?? .zero + + position.x += offset.width + position.y += offset.height + + self.position = position + return position } func hitTest(nodeId: NodeId, point: CGPoint, layout: LayoutConstants) -> Patch.HitTestResult? { for (inputIndex, input) in inputs.enumerated() { if inputRect(input: inputIndex, layout: layout).contains(point) { - return .input(nodeId, input.id) + return .input(InputID(nodeId, input.id)) } } for (outputIndex, output) in outputs.enumerated() { if outputRect(output: outputIndex, layout: layout).contains(point) { - return .output(nodeId, output.id) + return .output(OutputID(nodeId, output.id)) } } diff --git a/Sources/Flow/Model/Node+Layout.swift b/Sources/Flow/Model/Node+Layout.swift index f042e42..9163917 100644 --- a/Sources/Flow/Model/Node+Layout.swift +++ b/Sources/Flow/Model/Node+Layout.swift @@ -6,6 +6,7 @@ import Foundation public extension Node { /// Calculates the bounding rectangle for a node. func rect(layout: LayoutConstants) -> CGRect { + let position = position ?? .zero let maxio = CGFloat(max(inputs.count, outputs.count)) let size = CGSize(width: layout.nodeWidth, height: CGFloat((maxio * (layout.portSize.height + layout.portSpacing)) + layout.nodeTitleHeight + layout.portSpacing)) @@ -15,6 +16,7 @@ public extension Node { /// Calculates the bounding rectangle for an input port (not including the name). func inputRect(input: PortIndex, layout: LayoutConstants) -> CGRect { + let position = position ?? .zero let y = layout.nodeTitleHeight + CGFloat(input) * (layout.portSize.height + layout.portSpacing) + layout.portSpacing return CGRect(origin: position + CGSize(width: layout.portSpacing, height: y), size: layout.portSize) @@ -22,6 +24,7 @@ public extension Node { /// Calculates the bounding rectangle for an output port (not including the name). func outputRect(output: PortIndex, layout: LayoutConstants) -> CGRect { + let position = position ?? .zero let y = layout.nodeTitleHeight + CGFloat(output) * (layout.portSize.height + layout.portSpacing) + layout.portSpacing return CGRect(origin: position + CGSize(width: layout.nodeWidth - layout.portSpacing - layout.portSize.width, height: y), size: layout.portSize) diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index 0854e69..b415f0f 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -12,48 +12,27 @@ public typealias NodeId = UUID /// Using indices as IDs has proven to be easy and fast for our use cases. The ``Patch`` should be /// generated from your own data model, not used as your data model, so there isn't a requirement that /// the indices be consistent across your editing operations (such as deleting nodes). -public struct Node: Hashable, Equatable { - public let id: NodeId = UUID() - public var name: String - public var position: CGPoint - public var titleBarColor: Color +public protocol Node: AnyObject, ObservableObject, Hashable, Equatable { + associatedtype MiddleContent: View + + var id: NodeId { get } + var name: String { get set } + var position: CGPoint? { get set } + var titleBarColor: Color { get set } /// Is the node position fixed so it can't be edited in the UI? - public var locked = false - - public var inputs: [Port] - public var outputs: [Port] + var locked: Bool { get set } - @_disfavoredOverload - public init(name: String, - position: CGPoint = .zero, - titleBarColor: Color = Color.clear, - locked: Bool = false, - inputs: [Port] = [], - outputs: [Port] = []) - { - self.name = name - self.position = position - self.titleBarColor = titleBarColor - self.locked = locked - self.inputs = inputs - self.outputs = outputs - } + var inputs: PortsContainer { get } + @ViewBuilder var middleView: MiddleContent? { get } + var outputs: PortsContainer { get } + + func indexOfOutput(_ port: OutputID) -> Array.Index? + + func indexOfInput(_ port: InputID) -> Array.Index? +} - public init(name: String, - position: CGPoint = .zero, - titleBarColor: Color = Color.clear, - locked: Bool = false, - inputs: [String] = [], - outputs: [String] = []) - { - self.name = name - self.position = position - self.titleBarColor = titleBarColor - self.locked = locked - self.inputs = inputs.map { Port(name: $0) } - self.outputs = outputs.map { Port(name: $0) } - } +extension Node { public static func == (lhs: Self, rhs: Self) -> Bool { return lhs.id == rhs.id @@ -63,17 +42,63 @@ public struct Node: Hashable, Equatable { hasher.combine(id) } - func indexOfOutput(_ port: OutputID) -> Array.Index? { + public func indexOfOutput(_ port: OutputID) -> Array.Index? { self.outputs.firstIndex { $0.id == port.portId } } - func indexOfInput(_ port: InputID) -> Array.Index? { + public func indexOfInput(_ port: InputID) -> Array.Index? { self.inputs.firstIndex { $0.id == port.portId } } } -extension Sequence where Element == Node { - subscript(withId id: NodeId) -> Node { +public class AnyNode: Node, Hashable { + private var node: any Node + + public var id: NodeId { node.id } + + public var name: String { + get { node.name } + set { node.name = newValue } + } + + public var position: CGPoint? { + get { node.position } + set { node.position = newValue } + } + + public var titleBarColor: Color { + get { node.titleBarColor } + set { node.titleBarColor = newValue } + } + + public var locked: Bool { + get { node.locked } + set { node.locked = newValue } + } + + public var inputs: PortsContainer { + get { node.inputs } + } + + public var outputs: PortsContainer { + get { node.outputs } + } + + public var middleView: AnyView? { + if let nodeMiddleView = node.middleView { + return AnyView(nodeMiddleView) + } else { + return nil + } + } + + public init(_ node: some Node) { + self.node = node + } +} + +public extension Sequence where Element: Node { + subscript(withId id: NodeId) -> Element { get { guard let node = first(where: { $0.id == id }) else { fatalError("Node with identifier \(id.uuidString) not found") @@ -82,4 +107,26 @@ extension Sequence where Element == Node { return node } } + + subscript(portId outputId: OutputID) -> any PortProtocol { + get { + return self[withId: outputId.nodeId] + .outputs[withId: outputId.portId] + } + } + + subscript(portId inputId: InputID) -> any PortProtocol { + get { + return self[withId: inputId.nodeId] + .inputs[withId: inputId.portId] + } + } +} + +public extension Sequence where Element == any Node { + var asAnyNodeSet: Set { + Set(self.map({ node in + AnyNode(node) + })) + } } diff --git a/Sources/Flow/Model/Patch+Gestures.swift b/Sources/Flow/Model/Patch+Gestures.swift index f43b609..f315c45 100644 --- a/Sources/Flow/Model/Patch+Gestures.swift +++ b/Sources/Flow/Model/Patch+Gestures.swift @@ -6,8 +6,8 @@ import Foundation extension Patch { enum HitTestResult { case node(NodeId) - case input(NodeId, PortId) - case output(NodeId, PortId) + case input(InputID) + case output(OutputID) } /// Hit test a point against the whole patch. @@ -26,11 +26,10 @@ extension Patch { offset: CGSize, nodeMoved: NodeEditor.NodeMovedHandler ) { - var node = nodes[withId: nodeId] + let node = nodes[withId: nodeId] if !node.locked { - node.position += offset - nodes.update(with: node) - nodeMoved(nodeId, node.position) + let newPosition = node.translate(by: offset) + nodeMoved(nodeId, newPosition) } } diff --git a/Sources/Flow/Model/Patch+Layout.swift b/Sources/Flow/Model/Patch+Layout.swift index 4848c5f..3f1fadf 100644 --- a/Sources/Flow/Model/Patch+Layout.swift +++ b/Sources/Flow/Model/Patch+Layout.swift @@ -17,9 +17,8 @@ public extension Patch { ) -> (aggregateHeight: CGFloat, consumedNodeIndexes: Set) { - var node = nodes[withId: nodeId] + let node = nodes[withId: nodeId] node.position = point - nodes.update(with: node) // XXX: super slow let incomingWires = wires.filter { @@ -71,14 +70,13 @@ public extension Patch { let xPos = origin.x + (CGFloat(column) * (layout.nodeWidth + layout.nodeSpacing)) for nodeId in nodeStack { - var node = nodes[withId: nodeId] + let node = nodes[withId: nodeId] node.position = .init( x: xPos, y: origin.y + yOffset ) - nodes.update(with: node) - let nodeHeight = nodes[withId: nodeId].rect(layout: layout).height + let nodeHeight = node.rect(layout: layout).height yOffset += nodeHeight if column != columns.indices.last { yOffset += layout.nodeSpacing diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index 4b9a8ee..12e621c 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -10,10 +10,10 @@ import SwiftUI /// as well as a function to update your data model when the `Patch` changes. /// Use SwiftUI's `onChange(of:)` to monitor changes, or use `NodeEditor.onNodeAdded`, etc. public class Patch: ObservableObject { - @Published public var nodes: Set + @Published public var nodes: Set @Published public var wires: Set - public init(nodes: Set, wires: Set) { + public init(nodes: Set, wires: Set) { self.nodes = nodes self.wires = wires } diff --git a/Sources/Flow/Model/Port.swift b/Sources/Flow/Model/Port.swift index 8c4ef11..63d2c09 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -1,6 +1,7 @@ // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ import Foundation +import Combine /// Ports are identified by index within a node. public typealias PortIndex = Int @@ -15,7 +16,7 @@ public struct InputID: Equatable, Hashable { /// - Parameters: /// - node: The node the input belongs /// - portKeyPath: The keypath to access the Port on Node's input - public init(_ node: Node, _ portKeyPath: KeyPath<[Port], Port>) { + public init(_ node: any Node, _ portKeyPath: KeyPath<[any PortProtocol], any PortProtocol>) { self.nodeId = node.id self.portId = node.inputs[keyPath: portKeyPath].id } @@ -39,7 +40,7 @@ public struct OutputID: Equatable, Hashable { /// - Parameters: /// - node: The node the output belongs /// - portKeyPath: The keypath to access the Port on Node's outputs - public init(_ node: Node, _ portKeyPath: KeyPath<[Port], Port>) { + public init(_ node: any Node, _ portKeyPath: KeyPath<[any PortProtocol], any PortProtocol>) { self.nodeId = node.id self.portId = node.outputs[keyPath: portKeyPath].id } @@ -66,24 +67,82 @@ public enum PortType: Equatable, Hashable { case custom(String) } +public protocol PortProtocol: AnyObject, ObservableObject { + associatedtype T: Any + + var id: PortId { get } + var name: String { get } + var type: PortType { get } + var value: T? { get set } + var valueType: T.Type { get } + + func setPublisher(from port: (any PortProtocol)?) throws +} + /// Information for either an input or an output. -public struct Port: Equatable, Hashable, Identifiable { +public class Port: Identifiable, PortProtocol where T: Equatable { public let id: PortId = UUID() public let name: String public let type: PortType - - /// Initialize the port with a name and type - /// - Parameters: - /// - name: Descriptive label of the port - /// - type: Type of port - public init(name: String, type: PortType = .signal) { + @Published public var value: T? + public var valueType: T.Type + + private var portValueCancellable: Cancellable? + + enum PortError: Error { + case valueTypeMismatch + } + + public init(name: String, type: PortType = .signal, publisher: Published.Publisher? = nil, valueType: T.Type) { self.name = name self.type = type + self.valueType = valueType + } + + public func setPublisher(from port: (any PortProtocol)?) throws { + if let port { + guard let port = port as? Port else { throw PortError.valueTypeMismatch } + self.portValueCancellable = port.$value.removeDuplicates().sink { [weak self] newValue in + self?.value = newValue + } + } else { + portValueCancellable?.cancel() + portValueCancellable = nil + } + } +} + +public struct PortsContainer: Collection { + private var ports: [any PortProtocol] + + public var startIndex: Int { ports.startIndex } + public var endIndex: Int { ports.endIndex } + + public init(_ ports: [any PortProtocol]) { + self.ports = ports + } + + public func index(after i: Int) -> Int { + ports.index(after: i) + } + + public subscript(index: Array.Index) -> any PortProtocol { + ports[index] + } + + public subscript(withId id: PortId) -> any PortProtocol { + get { + return ports[withId: id] + } + } + + public subscript(keyPath keyPath: KeyPath<[any PortProtocol], any PortProtocol>) -> any PortProtocol { + ports[keyPath: keyPath] } } -extension Sequence where Element == Port { - subscript(id: PortId) -> Port { +public extension Sequence where Element == any PortProtocol { + subscript(withId id: PortId) -> any PortProtocol { get { guard let port = first(where: { $0.id == id }) else { fatalError("Port with identifier \(id.uuidString) not found") diff --git a/Sources/Flow/Views/NodeEditor+Drawing.swift b/Sources/Flow/Views/NodeEditor+Drawing.swift index 3576a71..aca19b9 100644 --- a/Sources/Flow/Views/NodeEditor+Drawing.swift +++ b/Sources/Flow/Views/NodeEditor+Drawing.swift @@ -41,7 +41,7 @@ extension NodeEditor { func drawInputPort( cx: GraphicsContext, - node: Node, + node: any Node, index: Int, offset: CGSize, portShading: GraphicsContext.Shading, @@ -68,7 +68,7 @@ extension NodeEditor { func drawOutputPort( cx: GraphicsContext, - node: Node, + node: any Node, index: Int, offset: CGSize, portShading: GraphicsContext.Shading, @@ -247,8 +247,7 @@ extension NodeEditor { func gradient(for outputID: OutputID) -> Gradient { let portType = patch - .nodes[withId: outputID.nodeId] - .outputs[outputID.portId] + .nodes[portId: outputID] .type return style.gradient(for: portType) ?? .init(colors: [.gray]) } diff --git a/Sources/Flow/Views/NodeEditor+Gestures.swift b/Sources/Flow/Views/NodeEditor+Gestures.swift index 466864e..9c812e9 100644 --- a/Sources/Flow/Views/NodeEditor+Gestures.swift +++ b/Sources/Flow/Views/NodeEditor+Gestures.swift @@ -1,6 +1,7 @@ // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ import SwiftUI +import os.log extension NodeEditor { /// State for all gestures. @@ -24,6 +25,15 @@ extension NodeEditor { return result } patch.wires.insert(wire) + + let output = patch.nodes[portId: wire.output] + let input = patch.nodes[portId: wire.input] + do { + try input.setPublisher(from: output) + } catch { + Logger().error("\(error.localizedDescription, privacy: .public)") + } + wireAdded(wire) } @@ -76,17 +86,14 @@ extension NodeEditor { b: location)) case let .node(nodeId): dragInfo = .node(id: nodeId, offset: translation) - case let .output(nodeId, portId): - dragInfo = DragInfo.wire(output: OutputID(nodeId, portId), offset: translation) - case let .input(nodeId, portId): + case let .output(outputID): + dragInfo = DragInfo.wire(output: outputID, offset: translation) + case let .input(inputId): // Is a wire attached to the input? - let inputId = InputID(nodeId, portId) if let attachedWire = attachedWire(inputID: inputId) { - - let inputNode = patch.nodes[withId: nodeId] + let inputNode = patch.nodes[withId: inputId.nodeId] let inputPortIndex = inputNode.indexOfInput(inputId) - let outputNode = patch.nodes[withId: attachedWire.output.nodeId] let outputIndex = outputNode.indexOfOutput(attachedWire.output) @@ -133,15 +140,15 @@ extension NodeEditor { ) } } - case let .output(nodeId, portId): - let type = patch.nodes[withId: nodeId].outputs[portId].type + case let .output(outputId): + let type = patch.nodes[portId: outputId].type if let input = findInput(point: location, type: type) { - connect(OutputID(nodeId, portId), to: input) + connect(outputId, to: input) } - case let .input(nodeId, portId): - let type = patch.nodes[withId: nodeId].inputs[portId].type + case let .input(inputId): + let type = patch.nodes[portId: inputId].type // Is a wire attached to the input? - if let attachedWire = attachedWire(inputID: InputID(nodeId, portId)) { + if let attachedWire = attachedWire(inputID: inputId) { patch.wires.remove(attachedWire) wireRemoved(attachedWire) if let input = findInput(point: location, type: type) { diff --git a/Sources/Flow/Views/NodeEditor+Rects.swift b/Sources/Flow/Views/NodeEditor+Rects.swift index 246c7dc..7fbe39c 100644 --- a/Sources/Flow/Views/NodeEditor+Rects.swift +++ b/Sources/Flow/Views/NodeEditor+Rects.swift @@ -25,7 +25,7 @@ public extension NodeEditor { } /// Search for inputs. - func findInput(node: Node, point: CGPoint, type: PortType) -> PortId? { + func findInput(node: any Node, point: CGPoint, type: PortType) -> PortId? { let inputPort = node.inputs.enumerated().first { portIndex, input in input.type == type && node.inputRect(input: portIndex, layout: layout).contains(point) }?.element @@ -45,7 +45,7 @@ public extension NodeEditor { } /// Search for outputs. - func findOutput(node: Node, point: CGPoint) -> PortId? { + func findOutput(node: any Node, point: CGPoint) -> PortId? { let outputPort = node.outputs.enumerated().first { portIndex, _ in node.outputRect(output: portIndex, layout: layout).contains(point) }?.element From 11c0f101f3fb14cba07c19b265acd1a9ea74e29f Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Tue, 18 Apr 2023 08:42:16 +0200 Subject: [PATCH 03/22] Remove PortsContainer --- Demo/Shared/ContentView.swift | 8 ++++---- Flow.playground/Contents.swift | 8 ++++---- Sources/Flow/Model/Node.swift | 8 ++++---- Sources/Flow/Model/Port.swift | 29 ----------------------------- 4 files changed, 12 insertions(+), 41 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index bee4152..919386b 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -12,13 +12,13 @@ class IntNode: Node { var locked: Bool = false - var inputs: PortsContainer = PortsContainer([ + var inputs: [any PortProtocol] = [ Port(name: "Value", valueType: Int.self) - ]) + ] - var outputs: PortsContainer = PortsContainer([ + var outputs: [any PortProtocol] = [ Port(name: "Value", valueType: Int.self) - ]) + ] @Published var value: Int? = nil diff --git a/Flow.playground/Contents.swift b/Flow.playground/Contents.swift index 8d3f63f..d86e7ae 100644 --- a/Flow.playground/Contents.swift +++ b/Flow.playground/Contents.swift @@ -10,13 +10,13 @@ class IntNode: Node { var titleBarColor: Color = .brown var locked: Bool = false - var inputs: PortsContainer = PortsContainer([ + var inputs: [any PortProtocol] = [ Port(name: "Value", valueType: Int.self) - ]) + ] - var outputs: PortsContainer = PortsContainer([ + var outputs: [any PortProtocol] = [ Port(name: "Value", valueType: Int.self) - ]) + ] @Published var value: Int? = nil diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index b415f0f..6393dac 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -23,9 +23,9 @@ public protocol Node: AnyObject, ObservableObject, Hashable, Equatable { /// Is the node position fixed so it can't be edited in the UI? var locked: Bool { get set } - var inputs: PortsContainer { get } + var inputs: [any PortProtocol] { get } @ViewBuilder var middleView: MiddleContent? { get } - var outputs: PortsContainer { get } + var outputs: [any PortProtocol] { get } func indexOfOutput(_ port: OutputID) -> Array.Index? @@ -76,11 +76,11 @@ public class AnyNode: Node, Hashable { set { node.locked = newValue } } - public var inputs: PortsContainer { + public var inputs: [any PortProtocol] { get { node.inputs } } - public var outputs: PortsContainer { + public var outputs: [any PortProtocol] { get { node.outputs } } diff --git a/Sources/Flow/Model/Port.swift b/Sources/Flow/Model/Port.swift index 63d2c09..28d3afd 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -112,35 +112,6 @@ public class Port: Identifiable, PortProtocol where T: Equatable { } } -public struct PortsContainer: Collection { - private var ports: [any PortProtocol] - - public var startIndex: Int { ports.startIndex } - public var endIndex: Int { ports.endIndex } - - public init(_ ports: [any PortProtocol]) { - self.ports = ports - } - - public func index(after i: Int) -> Int { - ports.index(after: i) - } - - public subscript(index: Array.Index) -> any PortProtocol { - ports[index] - } - - public subscript(withId id: PortId) -> any PortProtocol { - get { - return ports[withId: id] - } - } - - public subscript(keyPath keyPath: KeyPath<[any PortProtocol], any PortProtocol>) -> any PortProtocol { - ports[keyPath: keyPath] - } -} - public extension Sequence where Element == any PortProtocol { subscript(withId id: PortId) -> any PortProtocol { get { From a4f095450a7e3b47ef011ba8e2cf721fe003c9b8 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Tue, 18 Apr 2023 10:00:35 +0200 Subject: [PATCH 04/22] Introduce BaseNode --- Demo/Shared/ContentView.swift | 50 +++++++++++++++++------------------ Sources/Flow/Model/Node.swift | 25 ++++++++++++++++++ 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index 919386b..b2574b2 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -4,26 +4,20 @@ import SwiftUI class IntNode: Node { var id: NodeId = UUID() - var name: String - - var position: CGPoint? - - var titleBarColor: Color = .brown - - var locked: Bool = false - - var inputs: [any PortProtocol] = [ - Port(name: "Value", valueType: Int.self) - ] +class IntNode: BaseNode { - var outputs: [any PortProtocol] = [ - Port(name: "Value", valueType: Int.self) - ] + struct IntMiddleView: View { + @Binding var valueBinding: String + + var body: some View { + HStack { + TextField("Integer", text: $valueBinding) + } + } + } @Published var value: Int? = nil - @State var valueState: Int? = nil - var valueBinding: Binding { Binding( get: { self.value?.description ?? "" }, @@ -32,17 +26,21 @@ class IntNode: Node { } ) } - - var middleView: (some View)? { - HStack { - Text("The connected value is \(value?.description ?? "")") - TextField("Integer", text: valueBinding) - } - } - init(name: String, position: CGPoint? = nil) { - self.name = name - self.position = position + override init(name: String, position: CGPoint? = nil) { + super.init(name: name, position: position) + + inputs = [ + Port(name: "Value", valueType: Int.self) + ] + + outputs = [ + Port(name: "Value", valueType: Int.self) + ] + + titleBarColor = .brown + + middleView = IntMiddleView(valueBinding: valueBinding) if let intInput = inputs[0] as? Flow.Port { intInput.$value.assign(to: &$value) diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index 6393dac..6a3a3d9 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -51,6 +51,31 @@ extension Node { } } +open class BaseNode: Node where T: View { + public typealias MiddleContent = T + + public var id: NodeId = UUID() + + public var name: String + + public var position: CGPoint? + + public var titleBarColor: Color = .mint + + public var locked: Bool = false + + open var inputs: [any PortProtocol] = [] + + open var outputs: [any PortProtocol] = [] + + open var middleView: MiddleContent? = nil + + public init(name: String, position: CGPoint? = nil) { + self.name = name + self.position = position + } +} + public class AnyNode: Node, Hashable { private var node: any Node From 5e13f2f383b34899e10540474a4c9e4dfdd17514 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Tue, 18 Apr 2023 11:11:47 +0200 Subject: [PATCH 05/22] Remove AnyNode --- Demo/Shared/ContentView.swift | 16 +++++---- Flow.playground/Contents.swift | 56 +++++++++++++++---------------- Sources/Flow/Model/Node.swift | 61 ++++------------------------------ Sources/Flow/Model/Patch.swift | 4 +-- 4 files changed, 46 insertions(+), 91 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index b2574b2..959529b 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -4,7 +4,7 @@ import SwiftUI class IntNode: Node { var id: NodeId = UUID() -class IntNode: BaseNode { +class IntNode: BaseNode { struct IntMiddleView: View { @Binding var valueBinding: String @@ -40,7 +40,9 @@ class IntNode: BaseNode { titleBarColor = .brown - middleView = IntMiddleView(valueBinding: valueBinding) + setMiddleView { + IntMiddleView(valueBinding: valueBinding) + } if let intInput = inputs[0] as? Flow.Port { intInput.$value.assign(to: &$value) @@ -56,20 +58,20 @@ func simplePatch() -> Patch { let int1 = IntNode(name: "Integer 1") let int2 = IntNode(name: "Integer 2") - let nodes: [any Node] = [int1, int2] + let nodes: Set = Set([int1, int2]) let wires = Set([ Wire(from: OutputID(int1, \.[0]), to: InputID(int2, \.[0])) ]) - let patch = Patch(nodes: nodes.asAnyNodeSet, wires: wires) + let patch = Patch(nodes: nodes, wires: wires) patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 800, y: 50)) return patch } /// Bit of a stress test to show how Flow performs with more nodes. func randomPatch() -> Patch { - var randomNodes: [any Node] = [] + var randomNodes: [BaseNode] = [] for n in 0 ..< 50 { let randomPoint = CGPoint(x: 1000 * Double.random(in: 0 ... 1), y: 1000 * Double.random(in: 0 ... 1)) @@ -85,7 +87,7 @@ func randomPatch() -> Patch { ) ) } - return Patch(nodes: randomNodes.asAnyNodeSet, wires: randomWires) + return Patch(nodes: Set(randomNodes), wires: randomWires) } struct ContentView: View { @@ -94,7 +96,7 @@ struct ContentView: View { func addNode() { let newNode = IntNode(name: "Integer") - patch.nodes.insert(AnyNode(newNode)) + patch.nodes.insert(newNode) } var body: some View { diff --git a/Flow.playground/Contents.swift b/Flow.playground/Contents.swift index d86e7ae..789746a 100644 --- a/Flow.playground/Contents.swift +++ b/Flow.playground/Contents.swift @@ -2,26 +2,20 @@ import Flow import PlaygroundSupport import SwiftUI -class IntNode: Node { - var id: NodeId = UUID() - - var name: String - var position: CGPoint? - var titleBarColor: Color = .brown - var locked: Bool = false - - var inputs: [any PortProtocol] = [ - Port(name: "Value", valueType: Int.self) - ] +class IntNode: BaseNode { - var outputs: [any PortProtocol] = [ - Port(name: "Value", valueType: Int.self) - ] + struct IntMiddleView: View { + @Binding var valueBinding: String + + var body: some View { + HStack { + TextField("Integer", text: $valueBinding) + } + } + } @Published var value: Int? = nil - @State var valueState: Int? = nil - var valueBinding: Binding { Binding( get: { self.value?.description ?? "" }, @@ -30,17 +24,23 @@ class IntNode: Node { } ) } - - var middleView: (some View)? { - HStack { - Text("The connected value is \(value?.description ?? "")") - TextField("Integer", text: valueBinding) - } - } - init(name: String, position: CGPoint? = nil) { - self.name = name - self.position = position + override init(name: String, position: CGPoint? = nil) { + super.init(name: name, position: position) + + inputs = [ + Port(name: "Value", valueType: Int.self) + ] + + outputs = [ + Port(name: "Value", valueType: Int.self) + ] + + titleBarColor = .brown + + setMiddleView { + IntMiddleView(valueBinding: valueBinding) + } if let intInput = inputs[0] as? Flow.Port { intInput.$value.assign(to: &$value) @@ -56,13 +56,13 @@ func simplePatch() -> Patch { let int1 = IntNode(name: "Integer 1") let int2 = IntNode(name: "Integer 2") - let nodes: [any Node] = [int1, int2] + let nodes: Set = Set([int1, int2]) let wires = Set([ Wire(from: OutputID(int1, \.[0]), to: InputID(int2, \.[0])) ]) - let patch = Patch(nodes: nodes.asAnyNodeSet, wires: wires) + let patch = Patch(nodes: nodes, wires: wires) patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 800, y: 50)) return patch } diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index 6a3a3d9..4865ee7 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -51,9 +51,7 @@ extension Node { } } -open class BaseNode: Node where T: View { - public typealias MiddleContent = T - +open class BaseNode: Node { public var id: NodeId = UUID() public var name: String @@ -68,58 +66,21 @@ open class BaseNode: Node where T: View { open var outputs: [any PortProtocol] = [] - open var middleView: MiddleContent? = nil + private(set) public var middleView: AnyView? public init(name: String, position: CGPoint? = nil) { self.name = name self.position = position - } -} - -public class AnyNode: Node, Hashable { - private var node: any Node - - public var id: NodeId { node.id } - - public var name: String { - get { node.name } - set { node.name = newValue } + } - public var position: CGPoint? { - get { node.position } - set { node.position = newValue } - } - - public var titleBarColor: Color { - get { node.titleBarColor } - set { node.titleBarColor = newValue } - } - - public var locked: Bool { - get { node.locked } - set { node.locked = newValue } - } - - public var inputs: [any PortProtocol] { - get { node.inputs } - } - - public var outputs: [any PortProtocol] { - get { node.outputs } - } - - public var middleView: AnyView? { - if let nodeMiddleView = node.middleView { - return AnyView(nodeMiddleView) + public func setMiddleView(@ViewBuilder _ view: () -> (any View)? = { nil }) { + if let view = view() { + self.middleView = AnyView(view) } else { - return nil + self.middleView = nil } } - - public init(_ node: some Node) { - self.node = node - } } public extension Sequence where Element: Node { @@ -147,11 +108,3 @@ public extension Sequence where Element: Node { } } } - -public extension Sequence where Element == any Node { - var asAnyNodeSet: Set { - Set(self.map({ node in - AnyNode(node) - })) - } -} diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index 12e621c..8f86b72 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -10,10 +10,10 @@ import SwiftUI /// as well as a function to update your data model when the `Patch` changes. /// Use SwiftUI's `onChange(of:)` to monitor changes, or use `NodeEditor.onNodeAdded`, etc. public class Patch: ObservableObject { - @Published public var nodes: Set + @Published public var nodes: Set @Published public var wires: Set - public init(nodes: Set, wires: Set) { + public init(nodes: Set, wires: Set) { self.nodes = nodes self.wires = wires } From 08d625480ec6a21709178a4bec0001f9ff542f44 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Tue, 18 Apr 2023 15:10:56 +0200 Subject: [PATCH 06/22] Tentative rendering refactor --- Sources/Flow/Model/Patch.swift | 14 ++++- Sources/Flow/Views/ConnectorView.swift | 25 ++++++++ Sources/Flow/Views/NodeEditor.swift | 45 +++++++++------ Sources/Flow/Views/NodeView.swift | 80 ++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 Sources/Flow/Views/ConnectorView.swift create mode 100644 Sources/Flow/Views/NodeView.swift diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index 8f86b72..cfa6414 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -10,11 +10,23 @@ import SwiftUI /// as well as a function to update your data model when the `Patch` changes. /// Use SwiftUI's `onChange(of:)` to monitor changes, or use `NodeEditor.onNodeAdded`, etc. public class Patch: ObservableObject { - @Published public var nodes: Set + @Published public var nodes: Set { + didSet { + if !nodes.symmetricDifference(oldValue).isEmpty { + } else { + nodesArray = Array(nodes) + } + } + } + @Published public var wires: Set + + @Published var nodesArray: [BaseNode] = [] public init(nodes: Set, wires: Set) { self.nodes = nodes self.wires = wires + + self.nodesArray = Array(nodes) } } diff --git a/Sources/Flow/Views/ConnectorView.swift b/Sources/Flow/Views/ConnectorView.swift new file mode 100644 index 0000000..dffa05b --- /dev/null +++ b/Sources/Flow/Views/ConnectorView.swift @@ -0,0 +1,25 @@ +// +// ConnectorView.swift +// +// +// Created by Alessio Nossa on 18/04/2023. +// + +import SwiftUI + +struct ConnectorView: View { + var connector: any PortProtocol + + var body: some View { + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + } +} + +struct ConnectorView_Previews: PreviewProvider { + static var previews: some View { + EmptyView() + // ConnectorView(connector: ) + } +} diff --git a/Sources/Flow/Views/NodeEditor.swift b/Sources/Flow/Views/NodeEditor.swift index 5b073e7..c9d1f6c 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -7,6 +7,9 @@ import SwiftUI /// Draws everything using a single Canvas with manual layout. We found this is faster than /// using a View for each Node. public struct NodeEditor: View { + + static let kEditorCoordinateSpaceName = "node-editor-coordinate-space" + /// Data model. @ObservedObject var patch: Patch @@ -72,25 +75,33 @@ public struct NodeEditor: View { public var body: some View { ZStack { - Canvas { cx, size in - - let viewport = CGRect(origin: toLocal(.zero), size: toLocal(size)) - cx.addFilter(.shadow(radius: 5)) - - cx.scaleBy(x: CGFloat(zoom), y: CGFloat(zoom)) - cx.translateBy(x: pan.width, y: pan.height) - - self.drawWires(cx: cx, viewport: viewport) - self.drawNodes(cx: cx, viewport: viewport) - self.drawDraggedWire(cx: cx) - self.drawSelectionRect(cx: cx) + ScrollViewReader { scrollProxy in + ScrollView([.horizontal, .vertical], showsIndicators: false) { + ForEach($patch.nodesArray, id: \.id) { nodeBinding in + NodeView(node: nodeBinding) + } + } } - WorkspaceView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) - #if os(macOS) - .gesture(commandGesture) - #endif - .gesture(dragGesture) +// Canvas { cx, size in +// +// let viewport = CGRect(origin: toLocal(.zero), size: toLocal(size)) +// cx.addFilter(.shadow(radius: 5)) +// +// cx.scaleBy(x: CGFloat(zoom), y: CGFloat(zoom)) +// cx.translateBy(x: pan.width, y: pan.height) +// +// self.drawWires(cx: cx, viewport: viewport) +// self.drawNodes(cx: cx, viewport: viewport) +// self.drawDraggedWire(cx: cx) +// self.drawSelectionRect(cx: cx) +// } +// WorkspaceView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) +// #if os(macOS) +// .gesture(commandGesture) +// #endif +// .gesture(dragGesture) } + .coordinateSpace(name: NodeEditor.kEditorCoordinateSpaceName) .onChange(of: pan) { newValue in transformChanged(newValue, zoom) } diff --git a/Sources/Flow/Views/NodeView.swift b/Sources/Flow/Views/NodeView.swift new file mode 100644 index 0000000..89ca2d6 --- /dev/null +++ b/Sources/Flow/Views/NodeView.swift @@ -0,0 +1,80 @@ +// +// NodeView.swift +// +// +// Created by Alessio Nossa on 18/04/2023. +// + +import SwiftUI + +struct NodeView: View { + @Binding var node: BaseNode + + var body: some View { + VStack { + HStack { + Text(node.name) + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.blue) + + HStack(alignment: .top, spacing: 8) { + VStack { + ForEach(node.inputs, id: \.id) { input in + ConnectorView(connector: input) + } + } + + node.middleView + + VStack { + ForEach(node.outputs, id: \.id) { output in + ConnectorView(connector: output) + } + } + } + .padding() + } + .background(.white) + .cornerRadius(8) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .named(NodeEditor.kEditorCoordinateSpaceName)) + .onChanged { value in + node.position? += value.translation + } + .onEnded({ value in + node.position?.x += value.translation.width + node.position?.y += value.translation.height + }) + ) + .shadow(radius: 5) + .position(node.position ?? .zero) + + } + + +} + +struct NodeView_Previews: PreviewProvider { + static func getTestNode(proxy: GeometryProxy) -> BaseNode { + let node = BaseNode(name: "Test", position: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) + node.setMiddleView { + Text("Middle view") + } + + return node + } + + static var previews: some View { + GeometryReader { proxy in + NodeView(node: .constant(getTestNode(proxy: proxy))) + + } + .background(.clear) + .previewLayout(.sizeThatFits) +// .previewLayout(.fixed(width: 250, height: 150)) + + } +} From 4b0fbf06c05594258ea1e9c2788e2e7f3a1d4595 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Tue, 18 Apr 2023 15:42:52 +0200 Subject: [PATCH 07/22] Refactor BaseNode and use array of Node instances --- Demo/Shared/ContentView.swift | 16 +++++++--------- Sources/Flow/Model/Node.swift | 17 +++++------------ Sources/Flow/Model/Patch.swift | 15 ++------------- Sources/Flow/Views/NodeEditor.swift | 3 ++- Sources/Flow/Views/NodeView.swift | 13 ++++++------- 5 files changed, 22 insertions(+), 42 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index 959529b..7f64b50 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -4,7 +4,7 @@ import SwiftUI class IntNode: Node { var id: NodeId = UUID() -class IntNode: BaseNode { +class IntNode: BaseNode { struct IntMiddleView: View { @Binding var valueBinding: String @@ -40,9 +40,7 @@ class IntNode: BaseNode { titleBarColor = .brown - setMiddleView { - IntMiddleView(valueBinding: valueBinding) - } + middleView = IntMiddleView(valueBinding: valueBinding) if let intInput = inputs[0] as? Flow.Port { intInput.$value.assign(to: &$value) @@ -58,20 +56,20 @@ func simplePatch() -> Patch { let int1 = IntNode(name: "Integer 1") let int2 = IntNode(name: "Integer 2") - let nodes: Set = Set([int1, int2]) + let nodes = [int1, int2] let wires = Set([ Wire(from: OutputID(int1, \.[0]), to: InputID(int2, \.[0])) ]) let patch = Patch(nodes: nodes, wires: wires) - patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 800, y: 50)) + patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 600, y: 50)) return patch } /// Bit of a stress test to show how Flow performs with more nodes. func randomPatch() -> Patch { - var randomNodes: [BaseNode] = [] + var randomNodes: [any Node] = [] for n in 0 ..< 50 { let randomPoint = CGPoint(x: 1000 * Double.random(in: 0 ... 1), y: 1000 * Double.random(in: 0 ... 1)) @@ -87,7 +85,7 @@ func randomPatch() -> Patch { ) ) } - return Patch(nodes: Set(randomNodes), wires: randomWires) + return Patch(nodes: randomNodes, wires: randomWires) } struct ContentView: View { @@ -96,7 +94,7 @@ struct ContentView: View { func addNode() { let newNode = IntNode(name: "Integer") - patch.nodes.insert(newNode) + patch.nodes.append(newNode) } var body: some View { diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index 4865ee7..9d5fc10 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -51,7 +51,9 @@ extension Node { } } -open class BaseNode: Node { +open class BaseNode: Node where T: View { + public typealias MiddleContent = T + public var id: NodeId = UUID() public var name: String @@ -66,24 +68,15 @@ open class BaseNode: Node { open var outputs: [any PortProtocol] = [] - private(set) public var middleView: AnyView? + open var middleView: T? = nil public init(name: String, position: CGPoint? = nil) { self.name = name self.position = position - - } - - public func setMiddleView(@ViewBuilder _ view: () -> (any View)? = { nil }) { - if let view = view() { - self.middleView = AnyView(view) - } else { - self.middleView = nil - } } } -public extension Sequence where Element: Node { +public extension Sequence where Element == any Node { subscript(withId id: NodeId) -> Element { get { guard let node = first(where: { $0.id == id }) else { diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index cfa6414..aaa83ea 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -10,23 +10,12 @@ import SwiftUI /// as well as a function to update your data model when the `Patch` changes. /// Use SwiftUI's `onChange(of:)` to monitor changes, or use `NodeEditor.onNodeAdded`, etc. public class Patch: ObservableObject { - @Published public var nodes: Set { - didSet { - if !nodes.symmetricDifference(oldValue).isEmpty { - } else { - nodesArray = Array(nodes) - } - } - } + @Published public var nodes: [any Node] @Published public var wires: Set - - @Published var nodesArray: [BaseNode] = [] - public init(nodes: Set, wires: Set) { + public init(nodes: [any Node], wires: Set) { self.nodes = nodes self.wires = wires - - self.nodesArray = Array(nodes) } } diff --git a/Sources/Flow/Views/NodeEditor.swift b/Sources/Flow/Views/NodeEditor.swift index c9d1f6c..0bb35d3 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -77,8 +77,9 @@ public struct NodeEditor: View { ZStack { ScrollViewReader { scrollProxy in ScrollView([.horizontal, .vertical], showsIndicators: false) { - ForEach($patch.nodesArray, id: \.id) { nodeBinding in + ForEach($patch.nodes, id: \.id) { nodeBinding in NodeView(node: nodeBinding) + } } } diff --git a/Sources/Flow/Views/NodeView.swift b/Sources/Flow/Views/NodeView.swift index 89ca2d6..8f4332b 100644 --- a/Sources/Flow/Views/NodeView.swift +++ b/Sources/Flow/Views/NodeView.swift @@ -8,7 +8,7 @@ import SwiftUI struct NodeView: View { - @Binding var node: BaseNode + @Binding var node: any Node var body: some View { VStack { @@ -27,7 +27,9 @@ struct NodeView: View { } } - node.middleView + if let middleView = node.middleView { + AnyView(middleView) + } VStack { ForEach(node.outputs, id: \.id) { output in @@ -58,11 +60,8 @@ struct NodeView: View { } struct NodeView_Previews: PreviewProvider { - static func getTestNode(proxy: GeometryProxy) -> BaseNode { - let node = BaseNode(name: "Test", position: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) - node.setMiddleView { - Text("Middle view") - } + static func getTestNode(proxy: GeometryProxy) -> BaseNode { + let node = BaseNode(name: "Test", position: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) return node } From 0ffda8dd87295c53597cf705218129f142276193 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Thu, 20 Apr 2023 00:11:57 +0200 Subject: [PATCH 08/22] Implement data flow and connections --- Demo/Shared/ContentView.swift | 41 ++--- Flow.playground/Contents.swift | 42 ++--- Sources/Flow/Model/Node.swift | 60 ++++++-- Sources/Flow/Model/Patch.swift | 99 +++++++++++- Sources/Flow/Model/Port.swift | 121 +++++++++++++-- Sources/Flow/Views/ConnectorView.swift | 144 +++++++++++++++++- Sources/Flow/Views/NodeEditor+Drawing.swift | 51 +++---- Sources/Flow/Views/NodeEditor+Gestures.swift | 36 +---- Sources/Flow/Views/NodeEditor+Modifiers.swift | 104 +++++++------ Sources/Flow/Views/NodeEditor+Style.swift | 45 +----- Sources/Flow/Views/NodeEditor.swift | 52 +++---- Sources/Flow/Views/NodeView.swift | 97 +++++++++--- 12 files changed, 613 insertions(+), 279 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index 7f64b50..e1cf877 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -4,43 +4,47 @@ import SwiftUI class IntNode: Node { var id: NodeId = UUID() -class IntNode: BaseNode { +class IntNode: BaseNode { struct IntMiddleView: View { - @Binding var valueBinding: String + @ObservedObject var node: IntNode + + var valueBinding: Binding { + Binding( + get: { + guard let value = node.value else { return "" } + return String(value) + }, + set: { newValue in + self.node.value = Int.init(newValue) + } + ) + } var body: some View { - HStack { - TextField("Integer", text: $valueBinding) - } + TextField("Integer", text: valueBinding) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .frame(width: 100) } } @Published var value: Int? = nil - var valueBinding: Binding { - Binding( - get: { self.value?.description ?? "" }, - set: { newValue in - self.value = Int.init(newValue) - } - ) - } - override init(name: String, position: CGPoint? = nil) { super.init(name: name, position: position) inputs = [ - Port(name: "Value", valueType: Int.self) + Port(name: "Value", type: .input, valueType: Int.self, parentNodeId: id) ] outputs = [ - Port(name: "Value", valueType: Int.self) + Port(name: "Value", type: .output, valueType: Int.self, parentNodeId: id) ] titleBarColor = .brown - middleView = IntMiddleView(valueBinding: valueBinding) + middleView = AnyView(IntMiddleView(node: self)) if let intInput = inputs[0] as? Flow.Port { intInput.$value.assign(to: &$value) @@ -69,7 +73,8 @@ func simplePatch() -> Patch { /// Bit of a stress test to show how Flow performs with more nodes. func randomPatch() -> Patch { - var randomNodes: [any Node] = [] + var randomNodes: [BaseNode] = [] + for n in 0 ..< 50 { let randomPoint = CGPoint(x: 1000 * Double.random(in: 0 ... 1), y: 1000 * Double.random(in: 0 ... 1)) diff --git a/Flow.playground/Contents.swift b/Flow.playground/Contents.swift index 789746a..64eb719 100644 --- a/Flow.playground/Contents.swift +++ b/Flow.playground/Contents.swift @@ -5,42 +5,44 @@ import SwiftUI class IntNode: BaseNode { struct IntMiddleView: View { - @Binding var valueBinding: String + @ObservedObject var node: IntNode + + var valueBinding: Binding { + Binding( + get: { + guard let value = node.value else { return "" } + return String(value) + }, + set: { newValue in + self.node.value = Int.init(newValue) + } + ) + } var body: some View { - HStack { - TextField("Integer", text: $valueBinding) - } + TextField("Integer", text: valueBinding) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .frame(width: 100) } } @Published var value: Int? = nil - var valueBinding: Binding { - Binding( - get: { self.value?.description ?? "" }, - set: { newValue in - self.value = Int.init(newValue) - } - ) - } - override init(name: String, position: CGPoint? = nil) { super.init(name: name, position: position) inputs = [ - Port(name: "Value", valueType: Int.self) + Port(name: "Value", type: .input, valueType: Int.self, parentNodeId: id) ] outputs = [ - Port(name: "Value", valueType: Int.self) + Port(name: "Value", type: .output, valueType: Int.self, parentNodeId: id) ] titleBarColor = .brown - setMiddleView { - IntMiddleView(valueBinding: valueBinding) - } + middleView = AnyView(IntMiddleView(node: self)) if let intInput = inputs[0] as? Flow.Port { intInput.$value.assign(to: &$value) @@ -56,14 +58,14 @@ func simplePatch() -> Patch { let int1 = IntNode(name: "Integer 1") let int2 = IntNode(name: "Integer 2") - let nodes: Set = Set([int1, int2]) + let nodes = [int1, int2] let wires = Set([ Wire(from: OutputID(int1, \.[0]), to: InputID(int2, \.[0])) ]) let patch = Patch(nodes: nodes, wires: wires) - patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 800, y: 50)) + patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 600, y: 50)) return patch } diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index 9d5fc10..4d1ba99 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -2,6 +2,7 @@ import CoreGraphics import SwiftUI +import Combine /// Nodes are identified by index in `Patch/nodes``. public typealias NodeIndex = Int @@ -13,7 +14,6 @@ public typealias NodeId = UUID /// generated from your own data model, not used as your data model, so there isn't a requirement that /// the indices be consistent across your editing operations (such as deleting nodes). public protocol Node: AnyObject, ObservableObject, Hashable, Equatable { - associatedtype MiddleContent: View var id: NodeId { get } var name: String { get set } @@ -24,7 +24,7 @@ public protocol Node: AnyObject, ObservableObject, Hashable, Equatable { var locked: Bool { get set } var inputs: [any PortProtocol] { get } - @ViewBuilder var middleView: MiddleContent? { get } + var middleView: AnyView? { get } var outputs: [any PortProtocol] { get } func indexOfOutput(_ port: OutputID) -> Array.Index? @@ -51,32 +51,74 @@ extension Node { } } -open class BaseNode: Node where T: View { - public typealias MiddleContent = T +open class BaseNode: Node, ObservableObject { public var id: NodeId = UUID() public var name: String - public var position: CGPoint? + @Published public var position: CGPoint? public var titleBarColor: Color = .mint public var locked: Bool = false - open var inputs: [any PortProtocol] = [] + @Published open var inputs: [any PortProtocol] = [] - open var outputs: [any PortProtocol] = [] + @Published open var outputs: [any PortProtocol] = [] - open var middleView: T? = nil + open var middleView: AnyView? = nil + + private var inputsCancellables: Set = [] + + private var outputsCancellables: Set = [] public init(name: String, position: CGPoint? = nil) { self.name = name self.position = position + + observePorts() + } + + private func observePorts() { + // Observe initial inputs + observeInputChildren() + + // Observe changes to the inputs array itself + $inputs + .sink { [weak self] _ in + self?.objectWillChange.send() + self?.observeInputChildren() + } + .store(in: &inputsCancellables) + + $outputs + .sink { [weak self] _ in + self?.objectWillChange.send() + self?.observeInputChildren() + } + .store(in: &outputsCancellables) + } + + private func observeInputChildren() { + inputsCancellables.removeAll() + inputs.forEach({ (input: any PortProtocol) in + input.forwardUpdatesTo(objectPublisher: self.objectWillChange) + .store(in: &inputsCancellables) + }) + } + + private func observeOutputChildren() { + outputsCancellables.removeAll() + outputs.forEach({ (output: any PortProtocol) in + output.forwardUpdatesTo(objectPublisher: self.objectWillChange) + .store(in: &outputsCancellables) + }) } } -public extension Sequence where Element == any Node { +//public extension Sequence where Element == any Node { +public extension Sequence where Element: Node { subscript(withId id: NodeId) -> Element { get { guard let node = first(where: { $0.id == id }) else { diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index aaa83ea..9077e0a 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -3,6 +3,8 @@ import CoreGraphics import Foundation import SwiftUI +import Combine +import os.log /// Data model for Flow. /// @@ -10,12 +12,101 @@ import SwiftUI /// as well as a function to update your data model when the `Patch` changes. /// Use SwiftUI's `onChange(of:)` to monitor changes, or use `NodeEditor.onNodeAdded`, etc. public class Patch: ObservableObject { - @Published public var nodes: [any Node] - @Published public var wires: Set + /// Wire added handler closure. + public typealias WireAddedHandler = (_ wire: Wire) -> Void + + /// Wire removed handler closure. + public typealias WireRemovedHandler = (_ wire: Wire) -> Void + + /// Called when a wire is added. + var wireAdded: Patch.WireAddedHandler = { _ in } + + /// Called when a wire is removed. + var wireRemoved: Patch.WireRemovedHandler = { _ in } + + @Published public var nodes: [BaseNode] + + @Published var wires: Set - public init(nodes: [any Node], wires: Set) { + public init(nodes: [BaseNode], wires: Set) { self.nodes = nodes - self.wires = wires + self.wires = Set() + + wires.forEach { wire in + connect(wire) + } + + observeNotes() + } + + private var nodesCancellables: Set = [] + + private func observeNotes() { + // Observe initial inputs + observeNodeChildren() + + // Observe changes to the inputs array itself + $nodes + .sink { [weak self] _ in + self?.objectWillChange.send() + self?.observeNodeChildren() + } + .store(in: &nodesCancellables) + } + + private func observeNodeChildren() { + nodesCancellables.removeAll() + nodes.forEach({ node in + node.objectWillChange.sink(receiveValue: { [weak self] _ in + self?.objectWillChange.send() + }) + .store(in: &nodesCancellables) + }) + } + +// public func connect(_ wire: Wire, wireRemoved: WireRemovedHandler? = nil, wireAdded: WireAddedHandler? = nil) { + public func connect(_ wire: Wire) { + let output = nodes[portId: wire.output] + let input = nodes[portId: wire.input] + + guard input.canConnectTo(port: output) else { return } + // Remove any other wires connected to the input. + wires = wires.filter { w in + let result = w.input != wire.input + if !result { + let outputPort = nodes[portId: w.output] + input.disconnect(from: outputPort) + wireRemoved(w) + } + return result + } + wires.insert(wire) + + do { + try input.connect(to: output) + } catch { + Logger().error("\(error.localizedDescription, privacy: .public)") + } + + wireAdded(wire) + } + + public func disconnect(_ wire: Wire) { + let output = nodes[portId: wire.output] + let input = nodes[portId: wire.input] + + input.disconnect(from: output) + wires.remove(wire) + wireRemoved(wire) + } + + /// Adds a new wire to the patch, ensuring that multiple wires aren't connected to an input. +// public func connect(_ output: OutputID, to input: InputID, wireRemoved: @escaping WireRemovedHandler, wireAdded: @escaping WireAddedHandler) { + public func connect(_ output: OutputID, to input: InputID) { + let wire = Wire(from: output, to: input) + +// connect(wire, wireRemoved: wireRemoved, wireAdded: wireAdded) + connect(wire) } } diff --git a/Sources/Flow/Model/Port.swift b/Sources/Flow/Model/Port.swift index 28d3afd..664f702 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -2,6 +2,7 @@ import Foundation import Combine +import SwiftUI /// Ports are identified by index within a node. public typealias PortIndex = Int @@ -61,57 +62,145 @@ public struct OutputID: Equatable, Hashable { /// connected to each other. Here we offer two common types /// as well as a custom option for your own types. XXX: not implemented yet public enum PortType: Equatable, Hashable { - case control + case input + case output +} + +public enum PortValue: Equatable { + public static func == (lhs: PortValue, rhs: PortValue) -> Bool { + switch (lhs, rhs) { + case (.int(_), .int(_)): return true + case (.string, .string): return true + case (.signal, .signal): return true + default: return false + } + } + + case int(Published.Publisher) + case string(Published.Publisher) case signal - case midi - case custom(String) } -public protocol PortProtocol: AnyObject, ObservableObject { +public protocol PortProtocol: AnyObject, ObservableObject, Identifiable { associatedtype T: Any var id: PortId { get } var name: String { get } var type: PortType { get } + var frame: CGRect? { get set } var value: T? { get set } var valueType: T.Type { get } - func setPublisher(from port: (any PortProtocol)?) throws + var nodeId: NodeId { get } + + var connectedToInputs: Set { get } + var connectedToOutputs: Set { get } + + func canConnectTo(port: any PortProtocol) -> Bool + func connect(to port: any PortProtocol) throws + func disconnect(from port: any PortProtocol) + func forwardUpdatesTo(objectPublisher: ObservableObjectPublisher) -> AnyCancellable + + func color(with style: NodeEditor.Style, isOutput: Bool) -> Color? + func gradient(with style: NodeEditor.Style) -> Gradient? } /// Information for either an input or an output. -public class Port: Identifiable, PortProtocol where T: Equatable { +public class Port: Identifiable, ObservableObject, PortProtocol where T: Equatable { + public let id: PortId = UUID() public let name: String public let type: PortType + @Published public var frame: CGRect? @Published public var value: T? public var valueType: T.Type + public var nodeId: NodeId + + private(set) public var connectedToInputs = Set() + private(set) public var connectedToOutputs = Set() + private var portValueCancellable: Cancellable? enum PortError: Error { case valueTypeMismatch } - public init(name: String, type: PortType = .signal, publisher: Published.Publisher? = nil, valueType: T.Type) { +// var valueContainer: PortValue + + public init(name: String, type: PortType, publisher: Published.Publisher? = nil, valueType: T.Type, parentNodeId: NodeId) { self.name = name self.type = type self.valueType = valueType + + self.nodeId = parentNodeId } - public func setPublisher(from port: (any PortProtocol)?) throws { - if let port { - guard let port = port as? Port else { throw PortError.valueTypeMismatch } - self.portValueCancellable = port.$value.removeDuplicates().sink { [weak self] newValue in - self?.value = newValue - } - } else { - portValueCancellable?.cancel() - portValueCancellable = nil + public func canConnectTo(port: any PortProtocol) -> Bool { + if port is Port { + return true + } + + return false + } + + public func connect(to port: any PortProtocol) throws { + guard let port = port as? Port else { throw PortError.valueTypeMismatch } + self.portValueCancellable = port.$value.removeDuplicates().sink { [weak self] newValue in + self?.value = newValue + } + + switch port.type { + case .input: + connectedToInputs.insert(InputID(port.nodeId, port.id)) + case .output: + connectedToOutputs.insert(OutputID(port.nodeId, port.id)) + } + } + + public func disconnect(from port: any PortProtocol) { + switch port.type { + case .input: + connectedToInputs.remove(InputID(port.nodeId, port.id)) + case .output: + connectedToOutputs.remove(OutputID(port.nodeId, port.id)) + port.disconnect(from: self) + } + + portValueCancellable?.cancel() + portValueCancellable = nil + } + + public func forwardUpdatesTo(objectPublisher: ObservableObjectPublisher) -> AnyCancellable { + self.objectWillChange.sink { [weak objectPublisher] _ in + objectPublisher?.send() + } + } +} + +extension Port { + /// Returns input or output port color for the specified port type. + public func color(with style: NodeEditor.Style, isOutput: Bool) -> Color? { + switch valueType { + case is Int.Type: + return isOutput ? style.intWire.outputColor : style.intWire.inputColor + default: + return isOutput ? style.defaultWire.outputColor : style.defaultWire.inputColor + } + } + + /// Returns port gradient for the specified port type. + public func gradient(with style: NodeEditor.Style) -> Gradient? { + switch valueType { + case is Int.Type: + return style.intWire.gradient + default: + return style.defaultWire.gradient } } } +//public extension Sequence where Element == any PortProtocol { // doesn't work public extension Sequence where Element == any PortProtocol { subscript(withId id: PortId) -> any PortProtocol { get { diff --git a/Sources/Flow/Views/ConnectorView.swift b/Sources/Flow/Views/ConnectorView.swift index dffa05b..b246158 100644 --- a/Sources/Flow/Views/ConnectorView.swift +++ b/Sources/Flow/Views/ConnectorView.swift @@ -8,12 +8,150 @@ import SwiftUI struct ConnectorView: View { + + @EnvironmentObject var patch: Patch var connector: any PortProtocol + var gestureState: GestureState + + var isDragging: Bool { + if case let NodeEditor.DragInfo.wire(outputId, _, _, _) = gestureState.wrappedValue { + return (outputId.portId == connector.id) && (outputId.nodeId == connector.nodeId) + } + return false + } + + var isPossibleInput: Bool { + if case let NodeEditor.DragInfo.wire(_, _, _, possibleInputId) = gestureState.wrappedValue, + let possibleInputId { + return (possibleInputId.portId == connector.id) && (possibleInputId.nodeId == connector.nodeId) + } + return false + } + + @State private var previousPosition: CGPoint? + @State private var draggingWire: Bool = false + var body: some View { - Circle() - .fill(Color.red) - .frame(width: 10, height: 10) + HStack { + if connector.type == .output { + Text(connector.name) + } + + ZStack(alignment: .center) { + GeometryReader { proxy in + Circle() + .fill(Color.red) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + connector.frame = proxy.frame(in: .named(NodeEditor.kEditorCoordinateSpaceName)) + } + .onChange(of: proxy.frame(in: .named(NodeEditor.kEditorCoordinateSpaceName))) { newValue in + connector.frame = newValue + } + } + if true { + Circle() + .fill(Color.black) + .frame(width: 8, height: 8) + } + } + .frame(width: 16, height: 16) + .scaleEffect((isDragging || isPossibleInput) ? 1.2 : 1.0) + .gesture(dragGesture) + + if connector.type == .input { + Text(connector.name) + } + } + .animation(.easeInOut, value: isDragging) + .animation(.easeInOut, value: isPossibleInput) + } + + var dragGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .named(NodeEditor.kEditorCoordinateSpaceName)) + .updating(gestureState, body: { dragValue, dragState, transaction in + switch connector.type { + case .input: + guard let attachedWire = patch.wires.first(where: { $0.input.portId == connector.id }) else { return } + + guard let connectorFrame = connector.frame, + let outputFrame = patch.nodes[portId: attachedWire.output].frame + else { return } + + // (inputCenter - outputCenter) + dragValue.translation - (inputCenter - dragValue.startLocation) + let originDifference = connectorFrame.center - dragValue.startLocation + let offset = (connectorFrame.center - outputFrame.center) + dragValue.translation - originDifference + let possibleInputPortId = findPossibleInputPortId(outputFrame: outputFrame, offset: offset) + + dragState = .wire(output: attachedWire.output, + offset: offset, + hideWire: attachedWire, + possibleInputId: possibleInputPortId) + case .output: + let outputId = OutputID(connector.nodeId, connector.id) + guard let connectorFrame = connector.frame else { return } + let originDifference = connectorFrame.center - dragValue.startLocation + let offset = dragValue.translation - originDifference + let possibleInputPortId = findPossibleInputPortId(outputFrame: connectorFrame, offset: offset) + + dragState = NodeEditor.DragInfo.wire(output: outputId, offset: offset, possibleInputId: possibleInputPortId) + } + }) + .onEnded { value in + switch connector.type { + case .input: + guard let attachedWire = patch.wires.first(where: { $0.input.portId == connector.id }) else { return } + let outputPort = patch.nodes[portId: attachedWire.output] + guard let connectorFrame = connector.frame, + let outputFrame = outputPort.frame + else { return } + + // (inputCenter - outputCenter) + dragValue.translation - (inputCenter - dragValue.startLocation) + let originDifference = connectorFrame.center - value.startLocation + let offset = (connectorFrame.center - outputFrame.center) + value.translation - originDifference + if let possibleInputPort = findPossibleInputPort(outputFrame: outputFrame, offset: offset) { + let outputId = OutputID(outputPort.nodeId, outputPort.id) + let inputId = InputID(possibleInputPort.nodeId, possibleInputPort.id) + let newWire = Wire(from: outputId, to: inputId) + patch.connect(newWire) + } else { + patch.disconnect(attachedWire) + } + case .output: + guard let connectorFrame = connector.frame else { return } + let originDifference = connectorFrame.center - value.startLocation + let offset = value.translation - originDifference + + if let possibleInputPort = findPossibleInputPort(outputFrame: connectorFrame, offset: offset) { + let outputId = OutputID(connector.nodeId, connector.id) + let inputId = InputID(possibleInputPort.nodeId, possibleInputPort.id) + let newWire = Wire(from: outputId, to: inputId) + patch.connect(newWire) + } + } + } + } + + func findPossibleInputPort(outputFrame: CGRect, offset: CGSize) -> (any PortProtocol)? { + let point = outputFrame.center + offset + var inputPort: (any PortProtocol)? + _ = patch.nodes.reversed().first { node in + inputPort = node.inputs.first { inputPort in + inputPort.frame?.contains(point) ?? false + } + return inputPort != nil + } + + guard let inputPort, inputPort.canConnectTo(port: connector) else { return nil } + return inputPort + } + + func findPossibleInputPortId(outputFrame: CGRect, offset: CGSize) -> InputID? { + guard let possibleInputPort = findPossibleInputPort(outputFrame: outputFrame, offset: offset) else { + return nil + } + return InputID(possibleInputPort.nodeId, possibleInputPort.id) } } diff --git a/Sources/Flow/Views/NodeEditor+Drawing.swift b/Sources/Flow/Views/NodeEditor+Drawing.swift index aca19b9..8a4a326 100644 --- a/Sources/Flow/Views/NodeEditor+Drawing.swift +++ b/Sources/Flow/Views/NodeEditor+Drawing.swift @@ -36,7 +36,7 @@ extension GraphicsContext { extension NodeEditor { @inlinable @inline(__always) func color(for type: PortType, isOutput: Bool) -> Color { - style.color(for: type, isOutput: isOutput) ?? .gray + .gray } func drawInputPort( @@ -111,7 +111,7 @@ extension NodeEditor { return shading } - func drawNodes(cx: GraphicsContext, viewport: CGRect) { + func drawNodes(cx: GraphicsContext) { let connectedInputs = Set( patch.wires.map { wire in wire.input } ) let connectedOutputs = Set( patch.wires.map { wire in wire.output } ) @@ -126,7 +126,7 @@ extension NodeEditor { let offset = self.offset(for: node.id) let rect = node.rect(layout: layout).offset(by: offset) - guard rect.intersects(viewport) else { continue } +// guard rect.intersects(viewport) else { continue } let pos = rect.origin @@ -195,46 +195,29 @@ extension NodeEditor { } } - func drawWires(cx: GraphicsContext, viewport: CGRect) { + func drawWires(cx: GraphicsContext) { var hideWire: Wire? switch dragInfo { - case let .wire(_, _, hideWire: hw): + case let .wire(_, _, hideWire: hw, _): hideWire = hw default: hideWire = nil } for wire in patch.wires where wire != hideWire { - let fromNode = self.patch.nodes[withId: wire.output.nodeId] - guard let portIndexInFromNode = fromNode.indexOfOutput(wire.output) else { continue } - let fromPoint = fromNode.outputRect( - output: portIndexInFromNode, - layout: self.layout - ) - .offset(by: self.offset(for: wire.output.nodeId)).center - - let toNode = self.patch.nodes[withId: wire.input.nodeId] - guard let portIndexInToNode = toNode.indexOfInput(wire.input) else { continue } - let toPoint = toNode.inputRect( - input: portIndexInToNode, - layout: self.layout - ) - .offset(by: self.offset(for: wire.input.nodeId)).center - - let bounds = CGRect(origin: fromPoint, size: toPoint - fromPoint) - if viewport.intersects(bounds) { - let gradient = self.gradient(for: wire) - cx.strokeWire(from: fromPoint, to: toPoint, gradient: gradient) - } + guard let fromPoint = self.patch.nodes[portId: wire.output].frame?.center, + let toPoint = self.patch.nodes[portId: wire.input].frame?.center else { continue } + + let gradient = self.gradient(for: wire) + cx.strokeWire(from: fromPoint, to: toPoint, gradient: gradient) } } func drawDraggedWire(cx: GraphicsContext) { - if case let .wire(output: output, offset: offset, _) = dragInfo { - let fromNode = self.patch.nodes[withId: output.nodeId] - guard let portIndexInFromNode = fromNode.indexOfOutput(output) else { return } - let outputRect = fromNode.outputRect(output: portIndexInFromNode, layout: self.layout) + if case let .wire(output: output, offset: offset, _, _) = dragInfo { + guard let fromPoint = self.patch.nodes[portId: output].frame?.center else { return } + let gradient = self.gradient(for: output) - cx.strokeWire(from: outputRect.center, to: outputRect.center + offset, gradient: gradient) + cx.strokeWire(from: fromPoint, to: fromPoint + offset, gradient: gradient) } } @@ -246,10 +229,10 @@ extension NodeEditor { } func gradient(for outputID: OutputID) -> Gradient { - let portType = patch + let port = patch .nodes[portId: outputID] - .type - return style.gradient(for: portType) ?? .init(colors: [.gray]) + + return port.gradient(with: style) ?? .init(colors: [.gray]) } func gradient(for wire: Wire) -> Gradient { diff --git a/Sources/Flow/Views/NodeEditor+Gestures.swift b/Sources/Flow/Views/NodeEditor+Gestures.swift index 9c812e9..d8a0cf2 100644 --- a/Sources/Flow/Views/NodeEditor+Gestures.swift +++ b/Sources/Flow/Views/NodeEditor+Gestures.swift @@ -1,42 +1,16 @@ // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ import SwiftUI -import os.log extension NodeEditor { /// State for all gestures. - enum DragInfo { - case wire(output: OutputID, offset: CGSize = .zero, hideWire: Wire? = nil) + enum DragInfo: Equatable { + case wire(output: OutputID, offset: CGSize = .zero, hideWire: Wire? = nil, possibleInputId: InputID? = nil) case node(id: NodeId, offset: CGSize = .zero) case selection(rect: CGRect = .zero) case none } - /// Adds a new wire to the patch, ensuring that multiple wires aren't connected to an input. - func connect(_ output: OutputID, to input: InputID) { - let wire = Wire(from: output, to: input) - - // Remove any other wires connected to the input. - patch.wires = patch.wires.filter { w in - let result = w.input != wire.input - if !result { - wireRemoved(w) - } - return result - } - patch.wires.insert(wire) - - let output = patch.nodes[portId: wire.output] - let input = patch.nodes[portId: wire.input] - do { - try input.setPublisher(from: output) - } catch { - Logger().error("\(error.localizedDescription, privacy: .public)") - } - - wireAdded(wire) - } - func attachedWire(inputID: InputID) -> Wire? { patch.wires.first(where: { $0.input == inputID }) } @@ -143,16 +117,16 @@ extension NodeEditor { case let .output(outputId): let type = patch.nodes[portId: outputId].type if let input = findInput(point: location, type: type) { - connect(outputId, to: input) + patch.connect(outputId, to: input) } case let .input(inputId): let type = patch.nodes[portId: inputId].type // Is a wire attached to the input? if let attachedWire = attachedWire(inputID: inputId) { patch.wires.remove(attachedWire) - wireRemoved(attachedWire) + patch.wireRemoved(attachedWire) if let input = findInput(point: location, type: type) { - connect(attachedWire.output, to: input) + patch.connect(attachedWire.output, to: input) } } } diff --git a/Sources/Flow/Views/NodeEditor+Modifiers.swift b/Sources/Flow/Views/NodeEditor+Modifiers.swift index 8969286..25ca599 100644 --- a/Sources/Flow/Views/NodeEditor+Modifiers.swift +++ b/Sources/Flow/Views/NodeEditor+Modifiers.swift @@ -15,17 +15,15 @@ public extension NodeEditor { } /// Called when a wire is added. - func onWireAdded(_ handler: @escaping WireAddedHandler) -> Self { - var viewCopy = self - viewCopy.wireAdded = handler - return viewCopy + func onWireAdded(_ handler: @escaping Patch.WireAddedHandler) -> Self { + self.patch.wireAdded = handler + return self } /// Called when a wire is removed. - func onWireRemoved(_ handler: @escaping WireRemovedHandler) -> Self { - var viewCopy = self - viewCopy.wireRemoved = handler - return viewCopy + func onWireRemoved(_ handler: @escaping Patch.WireRemovedHandler) -> Self { + self.patch.wireRemoved = handler + return self } /// Called when the viewing transform has changed. @@ -44,49 +42,49 @@ public extension NodeEditor { return viewCopy } - /// Set the port color for a port type. - func portColor(for portType: PortType, _ color: Color) -> Self { - var viewCopy = self - - switch portType { - case .control: - viewCopy.style.controlWire.inputColor = color - viewCopy.style.controlWire.outputColor = color - case .signal: - viewCopy.style.signalWire.inputColor = color - viewCopy.style.signalWire.outputColor = color - case .midi: - viewCopy.style.midiWire.inputColor = color - viewCopy.style.midiWire.outputColor = color - case let .custom(id): - if viewCopy.style.customWires[id] == nil { - viewCopy.style.customWires[id] = .init() - } - viewCopy.style.customWires[id]?.inputColor = color - viewCopy.style.customWires[id]?.outputColor = color - } - - return viewCopy - } - - /// Set the port color for a port type to a gradient. - func portColor(for portType: PortType, _ gradient: Gradient) -> Self { - var viewCopy = self - - switch portType { - case .control: - viewCopy.style.controlWire.gradient = gradient - case .signal: - viewCopy.style.signalWire.gradient = gradient - case .midi: - viewCopy.style.midiWire.gradient = gradient - case let .custom(id): - if viewCopy.style.customWires[id] == nil { - viewCopy.style.customWires[id] = .init() - } - viewCopy.style.customWires[id]?.gradient = gradient - } - - return viewCopy - } +// /// Set the port color for a port type. +// func portColor(for portType: PortType, _ color: Color) -> Self { +// var viewCopy = self +// +// switch portType { +// case .control: +// viewCopy.style.controlWire.inputColor = color +// viewCopy.style.controlWire.outputColor = color +// case .signal: +// viewCopy.style.signalWire.inputColor = color +// viewCopy.style.signalWire.outputColor = color +// case .midi: +// viewCopy.style.midiWire.inputColor = color +// viewCopy.style.midiWire.outputColor = color +// case let .custom(id): +// if viewCopy.style.customWires[id] == nil { +// viewCopy.style.customWires[id] = .init() +// } +// viewCopy.style.customWires[id]?.inputColor = color +// viewCopy.style.customWires[id]?.outputColor = color +// } +// +// return viewCopy +// } +// +// /// Set the port color for a port type to a gradient. +// func portColor(for portType: PortType, _ gradient: Gradient) -> Self { +// var viewCopy = self +// +// switch portType { +// case .control: +// viewCopy.style.controlWire.gradient = gradient +// case .signal: +// viewCopy.style.signalWire.gradient = gradient +// case .midi: +// viewCopy.style.midiWire.gradient = gradient +// case let .custom(id): +// if viewCopy.style.customWires[id] == nil { +// viewCopy.style.customWires[id] = .init() +// } +// viewCopy.style.customWires[id]?.gradient = gradient +// } +// +// return viewCopy +// } } diff --git a/Sources/Flow/Views/NodeEditor+Style.swift b/Sources/Flow/Views/NodeEditor+Style.swift index b45f234..49acf03 100644 --- a/Sources/Flow/Views/NodeEditor+Style.swift +++ b/Sources/Flow/Views/NodeEditor+Style.swift @@ -8,46 +8,11 @@ public extension NodeEditor { /// Color used for rendering nodes. public var nodeColor: Color = .init(white: 0.3) - /// Color used for rendering control wires. - public var controlWire: WireStyle = .init() - - /// Color used for rendering signal wires. - public var signalWire: WireStyle = .init() - - /// Color used for rendering MIDI wires. - public var midiWire: WireStyle = .init() - - /// Colors used for rendering custom wires. - /// Dictionary is keyed by the custom wire name. - public var customWires: [String: WireStyle] = [:] - - /// Returns input or output port color for the specified port type. - public func color(for portType: PortType, isOutput: Bool) -> Color? { - switch portType { - case .control: - return isOutput ? controlWire.outputColor : controlWire.inputColor - case .signal: - return isOutput ? signalWire.outputColor : signalWire.inputColor - case .midi: - return isOutput ? midiWire.outputColor : midiWire.inputColor - case let .custom(id): - return isOutput ? customWires[id]?.outputColor : customWires[id]?.inputColor - } - } - - /// Returns port gradient for the specified port type. - public func gradient(for portType: PortType) -> Gradient? { - switch portType { - case .control: - return controlWire.gradient - case .signal: - return signalWire.gradient - case .midi: - return midiWire.gradient - case let .custom(id): - return customWires[id]?.gradient - } - } + /// Color used for rendering Integer wires. + public var intWire: WireStyle = .init() + + /// Color used for rendering Integer wires. + public var defaultWire: WireStyle = .init() } } diff --git a/Sources/Flow/Views/NodeEditor.swift b/Sources/Flow/Views/NodeEditor.swift index 0bb35d3..428dfbf 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -28,18 +28,6 @@ public struct NodeEditor: View { /// Called when a node is moved. var nodeMoved: NodeMovedHandler = { _, _ in } - - /// Wire added handler closure. - public typealias WireAddedHandler = (_ wire: Wire) -> Void - - /// Called when a wire is added. - var wireAdded: WireAddedHandler = { _ in } - - /// Wire removed handler closure. - public typealias WireRemovedHandler = (_ wire: Wire) -> Void - - /// Called when a wire is removed. - var wireRemoved: WireRemovedHandler = { _ in } /// Handler for pan or zoom. public typealias TransformChangedHandler = (_ pan: CGSize, _ zoom: CGFloat) -> Void @@ -74,35 +62,39 @@ public struct NodeEditor: View { @State var mousePosition: CGPoint = CGPoint(x: CGFloat.infinity, y: CGFloat.infinity) public var body: some View { - ZStack { + GeometryReader { geometryProxy in ScrollViewReader { scrollProxy in ScrollView([.horizontal, .vertical], showsIndicators: false) { - ForEach($patch.nodes, id: \.id) { nodeBinding in - NodeView(node: nodeBinding) + ZStack { + + Color.clear + .frame(width: 2000, height: 2000) + + + Canvas { cx, size in + self.drawWires(cx: cx) + self.drawNodes(cx: cx) + self.drawDraggedWire(cx: cx) + self.drawSelectionRect(cx: cx) + }.background(.green) + + ForEach(patch.nodes, id: \.id) { node in + NodeView(node: node, gestureState: $dragInfo) + .fixedSize() + } } + .coordinateSpace(name: NodeEditor.kEditorCoordinateSpaceName) + } } -// Canvas { cx, size in -// -// let viewport = CGRect(origin: toLocal(.zero), size: toLocal(size)) -// cx.addFilter(.shadow(radius: 5)) -// -// cx.scaleBy(x: CGFloat(zoom), y: CGFloat(zoom)) -// cx.translateBy(x: pan.width, y: pan.height) -// -// self.drawWires(cx: cx, viewport: viewport) -// self.drawNodes(cx: cx, viewport: viewport) -// self.drawDraggedWire(cx: cx) -// self.drawSelectionRect(cx: cx) -// } + } // WorkspaceView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) // #if os(macOS) // .gesture(commandGesture) // #endif // .gesture(dragGesture) - } - .coordinateSpace(name: NodeEditor.kEditorCoordinateSpaceName) + .environmentObject(patch) .onChange(of: pan) { newValue in transformChanged(newValue, zoom) } diff --git a/Sources/Flow/Views/NodeView.swift b/Sources/Flow/Views/NodeView.swift index 8f4332b..16838d7 100644 --- a/Sources/Flow/Views/NodeView.swift +++ b/Sources/Flow/Views/NodeView.swift @@ -6,9 +6,35 @@ // import SwiftUI +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif struct NodeView: View { - @Binding var node: any Node + @ObservedObject var node: BaseNode + + var gestureState: GestureState + + @State private var previousPosition: CGPoint? + + var dragging: Bool { + if case let .node(draggedId, _) = gestureState.wrappedValue { + let draggingNode = draggedId == node.id + return draggingNode + } + + return false + } + + var currentNodePosition: CGPoint? { + if dragging, case let .node(_, offset) = gestureState.wrappedValue { + return (node.position ?? .zero) + offset + } + return node.position + } var body: some View { VStack { @@ -19,56 +45,85 @@ struct NodeView: View { .frame(maxWidth: .infinity) .padding(.vertical, 8) .background(Color.blue) + .gesture(dragGesture) - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .center, spacing: 8) { VStack { ForEach(node.inputs, id: \.id) { input in - ConnectorView(connector: input) + ConnectorView(connector: input, gestureState: gestureState) } } - + .padding(.trailing, 8) + if let middleView = node.middleView { AnyView(middleView) } VStack { ForEach(node.outputs, id: \.id) { output in - ConnectorView(connector: output) + ConnectorView(connector: output, gestureState: gestureState) } } - } + .padding(.trailing, 8) + }.opacity(0.9) + .frame(maxWidth: .infinity, alignment: .leading) .padding() } - .background(.white) - .cornerRadius(8) - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .named(NodeEditor.kEditorCoordinateSpaceName)) - .onChanged { value in - node.position? += value.translation - } - .onEnded({ value in - node.position?.x += value.translation.width - node.position?.y += value.translation.height - }) + #if canImport(UIKit) + .background( + Color(UIColor.systemBackground) + .opacity(0.6) + ) + #endif + #if canImport(AppKit) + .background( + Color(NSColor.textBackgroundColor) + .opacity(0.6) ) + #endif + .cornerRadius(8) .shadow(radius: 5) - .position(node.position ?? .zero) + .scaleEffect(dragging ? 1.1 : 1.0) + .position(currentNodePosition ?? .zero) + .animation(.easeInOut, value: dragging) } + var dragGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .named(NodeEditor.kEditorCoordinateSpaceName)) + .updating(gestureState, body: { dragValue, dragState, transaction in + if gestureState.wrappedValue == NodeEditor.DragInfo.none + || dragging { + dragState = NodeEditor.DragInfo.node(id: node.id, offset: dragValue.translation) + } + }) + .onChanged { value in + if dragging && previousPosition == nil { + previousPosition = node.position ?? .zero + } + } + .onEnded { value in + guard let previousPosition else { return } + node.position = previousPosition + value.translation + + self.previousPosition = nil + } + } + + } struct NodeView_Previews: PreviewProvider { - static func getTestNode(proxy: GeometryProxy) -> BaseNode { - let node = BaseNode(name: "Test", position: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) + static func getTestNode(proxy: GeometryProxy) -> BaseNode { + let node = BaseNode(name: "Test", position: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) return node } static var previews: some View { GeometryReader { proxy in - NodeView(node: .constant(getTestNode(proxy: proxy))) + NodeView(node: getTestNode(proxy: proxy), gestureState: .init(initialValue: .none)) } .background(.clear) From e3e61be6d214d4181bdb9bdf671e9082f7ab4e6d Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Thu, 20 Apr 2023 00:21:32 +0200 Subject: [PATCH 09/22] Big cleanup of previous logic and rendering --- Sources/Flow/Model/Node+Gestures.swift | 35 ---- Sources/Flow/Model/Node+Layout.swift | 16 -- Sources/Flow/Model/Patch+Gestures.swift | 46 ----- Sources/Flow/Views/NodeEditor+Drawing.swift | 167 ------------------ Sources/Flow/Views/NodeEditor+Gestures.swift | 109 ------------ Sources/Flow/Views/NodeEditor+Modifiers.swift | 14 +- Sources/Flow/Views/NodeEditor+Rects.swift | 74 -------- Sources/Flow/Views/NodeEditor.swift | 4 - Sources/Flow/Views/TextCache.swift | 32 ---- 9 files changed, 7 insertions(+), 490 deletions(-) delete mode 100644 Sources/Flow/Model/Node+Gestures.swift delete mode 100644 Sources/Flow/Model/Patch+Gestures.swift delete mode 100644 Sources/Flow/Views/NodeEditor+Rects.swift delete mode 100644 Sources/Flow/Views/TextCache.swift diff --git a/Sources/Flow/Model/Node+Gestures.swift b/Sources/Flow/Model/Node+Gestures.swift deleted file mode 100644 index 7f982fb..0000000 --- a/Sources/Flow/Model/Node+Gestures.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ - -import CoreGraphics -import Foundation - -extension Node { - public func translate(by offset: CGSize) -> CGPoint { - var position = self.position ?? .zero - - position.x += offset.width - position.y += offset.height - - self.position = position - return position - } - - func hitTest(nodeId: NodeId, point: CGPoint, layout: LayoutConstants) -> Patch.HitTestResult? { - for (inputIndex, input) in inputs.enumerated() { - if inputRect(input: inputIndex, layout: layout).contains(point) { - return .input(InputID(nodeId, input.id)) - } - } - for (outputIndex, output) in outputs.enumerated() { - if outputRect(output: outputIndex, layout: layout).contains(point) { - return .output(OutputID(nodeId, output.id)) - } - } - - if rect(layout: layout).contains(point) { - return .node(nodeId) - } - - return nil - } -} diff --git a/Sources/Flow/Model/Node+Layout.swift b/Sources/Flow/Model/Node+Layout.swift index 9163917..0230d88 100644 --- a/Sources/Flow/Model/Node+Layout.swift +++ b/Sources/Flow/Model/Node+Layout.swift @@ -13,20 +13,4 @@ public extension Node { return CGRect(origin: position, size: size) } - - /// Calculates the bounding rectangle for an input port (not including the name). - func inputRect(input: PortIndex, layout: LayoutConstants) -> CGRect { - let position = position ?? .zero - let y = layout.nodeTitleHeight + CGFloat(input) * (layout.portSize.height + layout.portSpacing) + layout.portSpacing - return CGRect(origin: position + CGSize(width: layout.portSpacing, height: y), - size: layout.portSize) - } - - /// Calculates the bounding rectangle for an output port (not including the name). - func outputRect(output: PortIndex, layout: LayoutConstants) -> CGRect { - let position = position ?? .zero - let y = layout.nodeTitleHeight + CGFloat(output) * (layout.portSize.height + layout.portSpacing) + layout.portSpacing - return CGRect(origin: position + CGSize(width: layout.nodeWidth - layout.portSpacing - layout.portSize.width, height: y), - size: layout.portSize) - } } diff --git a/Sources/Flow/Model/Patch+Gestures.swift b/Sources/Flow/Model/Patch+Gestures.swift deleted file mode 100644 index f315c45..0000000 --- a/Sources/Flow/Model/Patch+Gestures.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ - -import CoreGraphics -import Foundation - -extension Patch { - enum HitTestResult { - case node(NodeId) - case input(InputID) - case output(OutputID) - } - - /// Hit test a point against the whole patch. - func hitTest(point: CGPoint, layout: LayoutConstants) -> HitTestResult? { - for node in nodes.reversed() { - if let result = node.hitTest(nodeId: node.id, point: point, layout: layout) { - return result - } - } - - return nil - } - - func moveNode( - nodeId: NodeId, - offset: CGSize, - nodeMoved: NodeEditor.NodeMovedHandler - ) { - let node = nodes[withId: nodeId] - if !node.locked { - let newPosition = node.translate(by: offset) - nodeMoved(nodeId, newPosition) - } - } - - func selected(in rect: CGRect, layout: LayoutConstants) -> Set { - var selection = Set() - - for node in nodes { - if rect.intersects(node.rect(layout: layout)) { - selection.insert(node.id) - } - } - return selection - } -} diff --git a/Sources/Flow/Views/NodeEditor+Drawing.swift b/Sources/Flow/Views/NodeEditor+Drawing.swift index 8a4a326..de0abb7 100644 --- a/Sources/Flow/Views/NodeEditor+Drawing.swift +++ b/Sources/Flow/Views/NodeEditor+Drawing.swift @@ -3,13 +3,6 @@ import SwiftUI extension GraphicsContext { - @inlinable @inline(__always) - func drawDot(in rect: CGRect, with shading: Shading) { - let dot = Path(ellipseIn: rect.insetBy(dx: rect.size.width / 3, dy: rect.size.height / 3)) - fill(dot, with: shading) - } - - func strokeWire( from: CGPoint, @@ -34,166 +27,6 @@ extension GraphicsContext { } extension NodeEditor { - @inlinable @inline(__always) - func color(for type: PortType, isOutput: Bool) -> Color { - .gray - } - - func drawInputPort( - cx: GraphicsContext, - node: any Node, - index: Int, - offset: CGSize, - portShading: GraphicsContext.Shading, - isConnected: Bool - ) { - let rect = node.inputRect(input: index, layout: layout).offset(by: offset) - let circle = Path(ellipseIn: rect) - let port = node.inputs[index] - - cx.fill(circle, with: portShading) - - if !isConnected { - cx.drawDot(in: rect, with: .color(.black)) - } else if rect.contains(toLocal(mousePosition)) { - cx.stroke(circle, with: .color(.white), style: .init(lineWidth: 1.0)) - } - - cx.draw( - textCache.text(string: port.name, font: layout.portNameFont, cx), - at: rect.center + CGSize(width: layout.portSize.width / 2 + layout.portSpacing, height: 0), - anchor: .leading - ) - } - - func drawOutputPort( - cx: GraphicsContext, - node: any Node, - index: Int, - offset: CGSize, - portShading: GraphicsContext.Shading, - isConnected: Bool - ) { - let rect = node.outputRect(output: index, layout: layout).offset(by: offset) - let circle = Path(ellipseIn: rect) - let port = node.outputs[index] - - cx.fill(circle, with: portShading) - - if !isConnected { - cx.drawDot(in: rect, with: .color(.black)) - } - - if rect.contains(toLocal(mousePosition)) { - cx.stroke(circle, with: .color(.white), style: .init(lineWidth: 1.0)) - } - - cx.draw(textCache.text(string: port.name, font: layout.portNameFont, cx), - at: rect.center + CGSize(width: -(layout.portSize.width / 2 + layout.portSpacing), height: 0), - anchor: .trailing) - } - - func inputShading(_ type: PortType, _ colors: inout [PortType: GraphicsContext.Shading], _ cx: GraphicsContext) -> GraphicsContext.Shading { - if let shading = colors[type] { - return shading - } - let shading = cx.resolve(.color(color(for: type, isOutput: false))) - colors[type] = shading - return shading - } - - func outputShading(_ type: PortType, _ colors: inout [PortType: GraphicsContext.Shading], _ cx: GraphicsContext) -> GraphicsContext.Shading { - if let shading = colors[type] { - return shading - } - let shading = cx.resolve(.color(color(for: type, isOutput: true))) - colors[type] = shading - return shading - } - - func drawNodes(cx: GraphicsContext) { - - let connectedInputs = Set( patch.wires.map { wire in wire.input } ) - let connectedOutputs = Set( patch.wires.map { wire in wire.output } ) - - let selectedShading = cx.resolve(.color(style.nodeColor.opacity(0.8))) - let unselectedShading = cx.resolve(.color(style.nodeColor.opacity(0.4))) - - var resolvedInputColors = [PortType: GraphicsContext.Shading]() - var resolvedOutputColors = [PortType: GraphicsContext.Shading]() - - for node in patch.nodes { - let offset = self.offset(for: node.id) - let rect = node.rect(layout: layout).offset(by: offset) - -// guard rect.intersects(viewport) else { continue } - - let pos = rect.origin - - let cornerRadius = layout.nodeCornerRadius - let bg = Path(roundedRect: rect, cornerRadius: cornerRadius) - - var selected = false - switch dragInfo { - case let .selection(rect: selectionRect): - selected = rect.intersects(selectionRect) - default: - selected = selection.contains(node.id) - } - - cx.fill(bg, with: selected ? selectedShading : unselectedShading) - - // Draw the title bar for the node. There seems to be - // no better cross-platform way to render a rectangle with the top - // two cornders rounded. - var titleBar = Path() - titleBar.move(to: CGPoint(x: 0, y: layout.nodeTitleHeight) + rect.origin.size) - titleBar.addLine(to: CGPoint(x: 0, y: cornerRadius) + rect.origin.size) - titleBar.addRelativeArc(center: CGPoint(x: cornerRadius, y: cornerRadius) + rect.origin.size, - radius: cornerRadius, - startAngle: .degrees(180), - delta: .degrees(90)) - titleBar.addLine(to: CGPoint(x: layout.nodeWidth - cornerRadius, y: 0) + rect.origin.size) - titleBar.addRelativeArc(center: CGPoint(x: layout.nodeWidth - cornerRadius, y: cornerRadius) + rect.origin.size, - radius: cornerRadius, - startAngle: .degrees(270), - delta: .degrees(90)) - titleBar.addLine(to: CGPoint(x: layout.nodeWidth, y: layout.nodeTitleHeight) + rect.origin.size) - titleBar.closeSubpath() - - cx.fill(titleBar, with: .color(node.titleBarColor)) - - if rect.contains(toLocal(mousePosition)) { - cx.stroke(bg, with: .color(.white), style: .init(lineWidth: 1.0)) - } - - cx.draw(textCache.text(string: node.name, font: layout.nodeTitleFont, cx), - at: pos + CGSize(width: rect.size.width / 2, height: layout.nodeTitleHeight / 2), - anchor: .center) - - for (i, input) in node.inputs.enumerated() { - drawInputPort( - cx: cx, - node: node, - index: i, - offset: offset, - portShading: inputShading(input.type, &resolvedInputColors, cx), - isConnected: connectedInputs.contains(InputID(node, \.[i])) - ) - } - - for (i, output) in node.outputs.enumerated() { - drawOutputPort( - cx: cx, - node: node, - index: i, - offset: offset, - portShading: outputShading(output.type, &resolvedOutputColors, cx), - isConnected: connectedOutputs.contains(OutputID(node, \.[i])) - ) - } - } - } func drawWires(cx: GraphicsContext) { var hideWire: Wire? diff --git a/Sources/Flow/Views/NodeEditor+Gestures.swift b/Sources/Flow/Views/NodeEditor+Gestures.swift index d8a0cf2..2d3d6c4 100644 --- a/Sources/Flow/Views/NodeEditor+Gestures.swift +++ b/Sources/Flow/Views/NodeEditor+Gestures.swift @@ -11,18 +11,6 @@ extension NodeEditor { case none } - func attachedWire(inputID: InputID) -> Wire? { - patch.wires.first(where: { $0.input == inputID }) - } - - func toLocal(_ p: CGPoint) -> CGPoint { - CGPoint(x: p.x / CGFloat(zoom), y: p.y / CGFloat(zoom)) - pan - } - - func toLocal(_ sz: CGSize) -> CGSize { - CGSize(width: sz.width / CGFloat(zoom), height: sz.height / CGFloat(zoom)) - } - #if os(macOS) var commandGesture: some Gesture { DragGesture(minimumDistance: 0).modifiers(.command).onEnded { drag in @@ -45,103 +33,6 @@ extension NodeEditor { } } #endif - - var dragGesture: some Gesture { - DragGesture(minimumDistance: 0) - .updating($dragInfo) { drag, dragInfo, _ in - - let startLocation = toLocal(drag.startLocation) - let location = toLocal(drag.location) - let translation = toLocal(drag.translation) - - switch patch.hitTest(point: startLocation, layout: layout) { - case .none: - dragInfo = .selection(rect: CGRect(a: startLocation, - b: location)) - case let .node(nodeId): - dragInfo = .node(id: nodeId, offset: translation) - case let .output(outputID): - dragInfo = DragInfo.wire(output: outputID, offset: translation) - case let .input(inputId): - // Is a wire attached to the input? - if let attachedWire = attachedWire(inputID: inputId) { - let inputNode = patch.nodes[withId: inputId.nodeId] - let inputPortIndex = inputNode.indexOfInput(inputId) - - let outputNode = patch.nodes[withId: attachedWire.output.nodeId] - let outputIndex = outputNode.indexOfOutput(attachedWire.output) - - guard let outputIndex, let inputPortIndex else { return } - let inputCenter = inputNode.inputRect(input: inputPortIndex, layout: layout).center - let outputCenter = outputNode.outputRect(output: outputIndex, layout: layout).center - - let offset = inputCenter - outputCenter + translation - dragInfo = .wire(output: attachedWire.output, - offset: offset, - hideWire: attachedWire) - } - } - } - .onEnded { drag in - - let startLocation = toLocal(drag.startLocation) - let location = toLocal(drag.location) - let translation = toLocal(drag.translation) - - let hitResult = patch.hitTest(point: startLocation, layout: layout) - - // Note that this threshold should be in screen coordinates. - if drag.distance > 5 { - switch hitResult { - case .none: - let selectionRect = CGRect(a: startLocation, b: location) - selection = self.patch.selected( - in: selectionRect, - layout: layout - ) - case let .node(nodeId): - patch.moveNode( - nodeId: nodeId, - offset: translation, - nodeMoved: self.nodeMoved - ) - if selection.contains(nodeId) { - for idx in selection where idx != nodeId { - patch.moveNode( - nodeId: idx, - offset: translation, - nodeMoved: self.nodeMoved - ) - } - } - case let .output(outputId): - let type = patch.nodes[portId: outputId].type - if let input = findInput(point: location, type: type) { - patch.connect(outputId, to: input) - } - case let .input(inputId): - let type = patch.nodes[portId: inputId].type - // Is a wire attached to the input? - if let attachedWire = attachedWire(inputID: inputId) { - patch.wires.remove(attachedWire) - patch.wireRemoved(attachedWire) - if let input = findInput(point: location, type: type) { - patch.connect(attachedWire.output, to: input) - } - } - } - } else { - // If we haven't moved far, then this is effectively a tap. - switch hitResult { - case .none: - selection = Set() - case let .node(nodeIndex): - selection = Set([nodeIndex]) - default: break - } - } - } - } } extension DragGesture.Value { diff --git a/Sources/Flow/Views/NodeEditor+Modifiers.swift b/Sources/Flow/Views/NodeEditor+Modifiers.swift index 25ca599..0385db2 100644 --- a/Sources/Flow/Views/NodeEditor+Modifiers.swift +++ b/Sources/Flow/Views/NodeEditor+Modifiers.swift @@ -35,13 +35,13 @@ public extension NodeEditor { // MARK: - Style Modifiers - /// Set the node color. - func nodeColor(_ color: Color) -> Self { - var viewCopy = self - viewCopy.style.nodeColor = color - return viewCopy - } - +// /// Set the node color. +// func nodeColor(_ color: Color) -> Self { +// var viewCopy = self +// viewCopy.style.nodeColor = color +// return viewCopy +// } +// // /// Set the port color for a port type. // func portColor(for portType: PortType, _ color: Color) -> Self { // var viewCopy = self diff --git a/Sources/Flow/Views/NodeEditor+Rects.swift b/Sources/Flow/Views/NodeEditor+Rects.swift deleted file mode 100644 index 7fbe39c..0000000 --- a/Sources/Flow/Views/NodeEditor+Rects.swift +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ - -import SwiftUI - -public extension NodeEditor { - /// Offset to apply to a node based on selection and gesture state. - func offset(for nodeId: NodeId) -> CGSize { - if patch.nodes[withId: nodeId].locked { - return .zero - } - switch dragInfo { - case let .node(id: id, offset: offset): - if nodeId == id { - return offset - } - if selection.contains(id), selection.contains(nodeId) { - // Offset other selected node only if we're dragging the - // selection. - return offset - } - default: - return .zero - } - return .zero - } - - /// Search for inputs. - func findInput(node: any Node, point: CGPoint, type: PortType) -> PortId? { - let inputPort = node.inputs.enumerated().first { portIndex, input in - input.type == type && node.inputRect(input: portIndex, layout: layout).contains(point) - }?.element - - return inputPort?.id - } - - /// Search for an input in the whole patch. - func findInput(point: CGPoint, type: PortType) -> InputID? { - // Search nodes in reverse to find nodes drawn on top first. - for node in patch.nodes.reversed() { - if let portId = findInput(node: node, point: point, type: type) { - return InputID(node.id, portId) - } - } - return nil - } - - /// Search for outputs. - func findOutput(node: any Node, point: CGPoint) -> PortId? { - let outputPort = node.outputs.enumerated().first { portIndex, _ in - node.outputRect(output: portIndex, layout: layout).contains(point) - }?.element - - return outputPort?.id - } - - /// Search for an output in the whole patch. - func findOutput(point: CGPoint) -> OutputID? { - // Search nodes in reverse to find nodes drawn on top first. - for node in patch.nodes.reversed() { - if let portId = findOutput(node: node, point: point) { - return OutputID(node.id, portId) - } - } - return nil - } - - /// Search for a node which intersects a point. - func findNode(point: CGPoint) -> NodeIndex? { - // Search nodes in reverse to find nodes drawn on top first. - patch.nodes.enumerated().reversed().first { _, node in - node.rect(layout: layout).contains(point) - }?.0 - } -} diff --git a/Sources/Flow/Views/NodeEditor.swift b/Sources/Flow/Views/NodeEditor.swift index 428dfbf..0602f60 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -19,9 +19,6 @@ public struct NodeEditor: View { /// State for all gestures. @GestureState var dragInfo = DragInfo.none - /// Cache resolved text - @StateObject var textCache = TextCache() - /// Node moved handler closure. public typealias NodeMovedHandler = (_ index: NodeId, _ location: CGPoint) -> Void @@ -74,7 +71,6 @@ public struct NodeEditor: View { Canvas { cx, size in self.drawWires(cx: cx) - self.drawNodes(cx: cx) self.drawDraggedWire(cx: cx) self.drawSelectionRect(cx: cx) }.background(.green) diff --git a/Sources/Flow/Views/TextCache.swift b/Sources/Flow/Views/TextCache.swift deleted file mode 100644 index 9f29163..0000000 --- a/Sources/Flow/Views/TextCache.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ - -import Foundation -import SwiftUI - -/// Caches "resolved" text. -/// -/// XXX: we will need to know when to clear the cache. -class TextCache: ObservableObject { - - struct Key: Equatable, Hashable { - var string: String - var font: Font - } - - var cache: [Key: GraphicsContext.ResolvedText] = [:] - - func text(string: String, - font: Font, - _ cx: GraphicsContext) -> GraphicsContext.ResolvedText { - - let key = Key(string: string, font: font) - - if let resolved = cache[key] { - return resolved - } - - let resolved = cx.resolve(Text(string).font(font)) - cache[key] = resolved - return resolved - } -} From de46fa96b54200c0b3c75ef79136392cacb7369b Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Thu, 20 Apr 2023 01:21:25 +0200 Subject: [PATCH 10/22] Improve connection UI --- Sources/Flow/Model/Patch.swift | 4 ++-- Sources/Flow/Model/Port.swift | 30 +++++++++++--------------- Sources/Flow/Views/ConnectorView.swift | 18 ++++++++++++++-- Sources/Flow/Views/NodeView.swift | 20 ++++++++--------- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index 9077e0a..bbf14b7 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -76,7 +76,7 @@ public class Patch: ObservableObject { let result = w.input != wire.input if !result { let outputPort = nodes[portId: w.output] - input.disconnect(from: outputPort) + try? input.disconnect(from: outputPort) wireRemoved(w) } return result @@ -96,7 +96,7 @@ public class Patch: ObservableObject { let output = nodes[portId: wire.output] let input = nodes[portId: wire.input] - input.disconnect(from: output) + try? input.disconnect(from: output) wires.remove(wire) wireRemoved(wire) } diff --git a/Sources/Flow/Model/Port.swift b/Sources/Flow/Model/Port.swift index 664f702..de8c5f1 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -98,7 +98,7 @@ public protocol PortProtocol: AnyObject, ObservableObject, Identifiable { func canConnectTo(port: any PortProtocol) -> Bool func connect(to port: any PortProtocol) throws - func disconnect(from port: any PortProtocol) + func disconnect(from port: any PortProtocol) throws func forwardUpdatesTo(objectPublisher: ObservableObjectPublisher) -> AnyCancellable func color(with style: NodeEditor.Style, isOutput: Bool) -> Color? @@ -124,6 +124,7 @@ public class Port: Identifiable, ObservableObject, PortProtocol where T: Equa enum PortError: Error { case valueTypeMismatch + case wrongPortType } // var valueContainer: PortValue @@ -144,28 +145,23 @@ public class Port: Identifiable, ObservableObject, PortProtocol where T: Equa return false } - public func connect(to port: any PortProtocol) throws { - guard let port = port as? Port else { throw PortError.valueTypeMismatch } + public func connect(to outputPort: any PortProtocol) throws { + guard type == .input else { throw PortError.wrongPortType } + guard let port = outputPort as? Port else { throw PortError.valueTypeMismatch } self.portValueCancellable = port.$value.removeDuplicates().sink { [weak self] newValue in self?.value = newValue } - switch port.type { - case .input: - connectedToInputs.insert(InputID(port.nodeId, port.id)) - case .output: - connectedToOutputs.insert(OutputID(port.nodeId, port.id)) - } + connectedToOutputs.insert(OutputID(port.nodeId, port.id)) + port.connectedToInputs.insert(InputID(nodeId, id)) } - public func disconnect(from port: any PortProtocol) { - switch port.type { - case .input: - connectedToInputs.remove(InputID(port.nodeId, port.id)) - case .output: - connectedToOutputs.remove(OutputID(port.nodeId, port.id)) - port.disconnect(from: self) - } + public func disconnect(from outputPort: any PortProtocol) throws { + guard type == .input else { throw PortError.wrongPortType } + guard let port = outputPort as? Port else { throw PortError.valueTypeMismatch } + + connectedToOutputs.remove(OutputID(port.nodeId, port.id)) + port.connectedToInputs.remove(InputID(nodeId, id)) portValueCancellable?.cancel() portValueCancellable = nil diff --git a/Sources/Flow/Views/ConnectorView.swift b/Sources/Flow/Views/ConnectorView.swift index b246158..5d1dfc5 100644 --- a/Sources/Flow/Views/ConnectorView.swift +++ b/Sources/Flow/Views/ConnectorView.swift @@ -29,13 +29,23 @@ struct ConnectorView: View { return false } + var isConnected: Bool { + switch connector.type { + case .input: + return !connector.connectedToOutputs.isEmpty + case .output: + return !connector.connectedToInputs.isEmpty + } + } + @State private var previousPosition: CGPoint? @State private var draggingWire: Bool = false var body: some View { - HStack { + HStack(spacing: 4) { if connector.type == .output { Text(connector.name) + .font(.caption) } ZStack(alignment: .center) { @@ -50,11 +60,13 @@ struct ConnectorView: View { connector.frame = newValue } } - if true { + + if isConnected { Circle() .fill(Color.black) .frame(width: 8, height: 8) } + } .frame(width: 16, height: 16) .scaleEffect((isDragging || isPossibleInput) ? 1.2 : 1.0) @@ -62,10 +74,12 @@ struct ConnectorView: View { if connector.type == .input { Text(connector.name) + .font(.caption) } } .animation(.easeInOut, value: isDragging) .animation(.easeInOut, value: isPossibleInput) + .animation(.easeInOut, value: isConnected) } var dragGesture: some Gesture { diff --git a/Sources/Flow/Views/NodeView.swift b/Sources/Flow/Views/NodeView.swift index 16838d7..def38cc 100644 --- a/Sources/Flow/Views/NodeView.swift +++ b/Sources/Flow/Views/NodeView.swift @@ -43,7 +43,7 @@ struct NodeView: View { .font(.headline) } .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .padding(.vertical, 16) .background(Color.blue) .gesture(dragGesture) @@ -53,34 +53,34 @@ struct NodeView: View { ConnectorView(connector: input, gestureState: gestureState) } } - .padding(.trailing, 8) + .padding(.leading, 8) if let middleView = node.middleView { AnyView(middleView) } - - VStack { + + VStack(alignment: .trailing) { ForEach(node.outputs, id: \.id) { output in ConnectorView(connector: output, gestureState: gestureState) } } .padding(.trailing, 8) - }.opacity(0.9) + } + .padding(.vertical, 16) .frame(maxWidth: .infinity, alignment: .leading) - .padding() } - #if canImport(UIKit) +#if canImport(UIKit) .background( Color(UIColor.systemBackground) .opacity(0.6) ) - #endif - #if canImport(AppKit) +#endif +#if canImport(AppKit) .background( Color(NSColor.textBackgroundColor) .opacity(0.6) ) - #endif +#endif .cornerRadius(8) .shadow(radius: 5) .scaleEffect(dragging ? 1.1 : 1.0) From 8f2fed7ba2a0ea1daad7da99caaebef171bbc4c7 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Thu, 20 Apr 2023 01:29:26 +0200 Subject: [PATCH 11/22] Added frame property to Node --- Sources/Flow/Model/Node.swift | 3 +++ Sources/Flow/Views/ConnectorView.swift | 2 +- Sources/Flow/Views/NodeView.swift | 12 +++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/Flow/Model/Node.swift b/Sources/Flow/Model/Node.swift index 4d1ba99..96206aa 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -18,6 +18,7 @@ public protocol Node: AnyObject, ObservableObject, Hashable, Equatable { var id: NodeId { get } var name: String { get set } var position: CGPoint? { get set } + var frame: CGRect? { get set } var titleBarColor: Color { get set } /// Is the node position fixed so it can't be edited in the UI? @@ -59,6 +60,8 @@ open class BaseNode: Node, ObservableObject { @Published public var position: CGPoint? + @Published public var frame: CGRect? + public var titleBarColor: Color = .mint public var locked: Bool = false diff --git a/Sources/Flow/Views/ConnectorView.swift b/Sources/Flow/Views/ConnectorView.swift index 5d1dfc5..f4bca33 100644 --- a/Sources/Flow/Views/ConnectorView.swift +++ b/Sources/Flow/Views/ConnectorView.swift @@ -68,7 +68,7 @@ struct ConnectorView: View { } } - .frame(width: 16, height: 16) + .frame(width: 20, height: 20) .scaleEffect((isDragging || isPossibleInput) ? 1.2 : 1.0) .gesture(dragGesture) diff --git a/Sources/Flow/Views/NodeView.swift b/Sources/Flow/Views/NodeView.swift index def38cc..c2bcf70 100644 --- a/Sources/Flow/Views/NodeView.swift +++ b/Sources/Flow/Views/NodeView.swift @@ -37,6 +37,7 @@ struct NodeView: View { } var body: some View { + GeometryReader { geometryProxy in VStack { HStack { Text(node.name) @@ -84,9 +85,18 @@ struct NodeView: View { .cornerRadius(8) .shadow(radius: 5) .scaleEffect(dragging ? 1.1 : 1.0) + .fixedSize() .position(currentNodePosition ?? .zero) .animation(.easeInOut, value: dragging) - + .onAppear { + node.frame = geometryProxy.frame(in: .named(NodeEditor.kEditorCoordinateSpaceName)) + } + .onChange(of: geometryProxy.frame(in: .named(NodeEditor.kEditorCoordinateSpaceName))) { newValue in + if newValue != node.frame { + node.frame = newValue + } + } + } } From 1fe0fd9c857cdf357597fe4724a8082efd3ef858 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Thu, 20 Apr 2023 02:06:18 +0200 Subject: [PATCH 12/22] Implement ValueUpdate and basic Nodes --- Demo/Shared/ContentView.swift | 55 +------------------ Flow.playground/Contents.swift | 52 ------------------ Sources/Flow/Model/Port.swift | 17 ++++-- Sources/Flow/Model/ValueUpdate.swift | 21 +++++++ Sources/Flow/Nodes/IntNode.swift | 64 ++++++++++++++++++++++ Sources/Flow/Nodes/TriggerButtonNode.swift | 40 ++++++++++++++ 6 files changed, 137 insertions(+), 112 deletions(-) create mode 100644 Sources/Flow/Model/ValueUpdate.swift create mode 100644 Sources/Flow/Nodes/IntNode.swift create mode 100644 Sources/Flow/Nodes/TriggerButtonNode.swift diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index e1cf877..b59308d 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -1,60 +1,7 @@ import Flow import SwiftUI +import Combine -class IntNode: Node { - var id: NodeId = UUID() - -class IntNode: BaseNode { - - struct IntMiddleView: View { - @ObservedObject var node: IntNode - - var valueBinding: Binding { - Binding( - get: { - guard let value = node.value else { return "" } - return String(value) - }, - set: { newValue in - self.node.value = Int.init(newValue) - } - ) - } - - var body: some View { - TextField("Integer", text: valueBinding) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .frame(width: 100) - } - } - - @Published var value: Int? = nil - - override init(name: String, position: CGPoint? = nil) { - super.init(name: name, position: position) - - inputs = [ - Port(name: "Value", type: .input, valueType: Int.self, parentNodeId: id) - ] - - outputs = [ - Port(name: "Value", type: .output, valueType: Int.self, parentNodeId: id) - ] - - titleBarColor = .brown - - middleView = AnyView(IntMiddleView(node: self)) - - if let intInput = inputs[0] as? Flow.Port { - intInput.$value.assign(to: &$value) - } - - if let intOutput = outputs[0] as? Flow.Port { - $value.assign(to: &intOutput.$value) - } - } -} func simplePatch() -> Patch { let int1 = IntNode(name: "Integer 1") diff --git a/Flow.playground/Contents.swift b/Flow.playground/Contents.swift index 64eb719..b37d11e 100644 --- a/Flow.playground/Contents.swift +++ b/Flow.playground/Contents.swift @@ -2,58 +2,6 @@ import Flow import PlaygroundSupport import SwiftUI -class IntNode: BaseNode { - - struct IntMiddleView: View { - @ObservedObject var node: IntNode - - var valueBinding: Binding { - Binding( - get: { - guard let value = node.value else { return "" } - return String(value) - }, - set: { newValue in - self.node.value = Int.init(newValue) - } - ) - } - - var body: some View { - TextField("Integer", text: valueBinding) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .frame(width: 100) - } - } - - @Published var value: Int? = nil - - override init(name: String, position: CGPoint? = nil) { - super.init(name: name, position: position) - - inputs = [ - Port(name: "Value", type: .input, valueType: Int.self, parentNodeId: id) - ] - - outputs = [ - Port(name: "Value", type: .output, valueType: Int.self, parentNodeId: id) - ] - - titleBarColor = .brown - - middleView = AnyView(IntMiddleView(node: self)) - - if let intInput = inputs[0] as? Flow.Port { - intInput.$value.assign(to: &$value) - } - - if let intOutput = outputs[0] as? Flow.Port { - $value.assign(to: &intOutput.$value) - } - } -} - func simplePatch() -> Patch { let int1 = IntNode(name: "Integer 1") let int2 = IntNode(name: "Integer 2") diff --git a/Sources/Flow/Model/Port.swift b/Sources/Flow/Model/Port.swift index de8c5f1..845041b 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -88,7 +88,8 @@ public protocol PortProtocol: AnyObject, ObservableObject, Identifiable { var name: String { get } var type: PortType { get } var frame: CGRect? { get set } - var value: T? { get set } +// var value: T? { get set } + var valueUpdate: ValueUpdate? { get set } var valueType: T.Type { get } var nodeId: NodeId { get } @@ -106,13 +107,14 @@ public protocol PortProtocol: AnyObject, ObservableObject, Identifiable { } /// Information for either an input or an output. -public class Port: Identifiable, ObservableObject, PortProtocol where T: Equatable { +public class Port: Identifiable, ObservableObject, PortProtocol { public let id: PortId = UUID() public let name: String public let type: PortType @Published public var frame: CGRect? - @Published public var value: T? +// @Published public var value: T? + @Published public var valueUpdate: ValueUpdate? public var valueType: T.Type public var nodeId: NodeId @@ -129,7 +131,7 @@ public class Port: Identifiable, ObservableObject, PortProtocol where T: Equa // var valueContainer: PortValue - public init(name: String, type: PortType, publisher: Published.Publisher? = nil, valueType: T.Type, parentNodeId: NodeId) { + public init(name: String, type: PortType, valueType: T.Type, parentNodeId: NodeId) { self.name = name self.type = type self.valueType = valueType @@ -148,8 +150,11 @@ public class Port: Identifiable, ObservableObject, PortProtocol where T: Equa public func connect(to outputPort: any PortProtocol) throws { guard type == .input else { throw PortError.wrongPortType } guard let port = outputPort as? Port else { throw PortError.valueTypeMismatch } - self.portValueCancellable = port.$value.removeDuplicates().sink { [weak self] newValue in - self?.value = newValue +// self.portValueCancellable = port.$value.removeDuplicates().sink { [weak self] newValue in +// self?.value = newValue +// } + self.portValueCancellable = port.$valueUpdate.removeDuplicates().sink { [weak self] newValue in + self?.valueUpdate = newValue } connectedToOutputs.insert(OutputID(port.nodeId, port.id)) diff --git a/Sources/Flow/Model/ValueUpdate.swift b/Sources/Flow/Model/ValueUpdate.swift new file mode 100644 index 0000000..459dfc6 --- /dev/null +++ b/Sources/Flow/Model/ValueUpdate.swift @@ -0,0 +1,21 @@ +// +// ValueUpdate.swift +// +// +// Created by Alessio Nossa on 20/04/2023. +// + +import Foundation + +public struct ValueUpdate: Identifiable, Equatable { + public let id = UUID() + public let value: T? + + public init(_ value: T?) { + self.value = value + } + + public static func == (lhs: ValueUpdate, rhs: ValueUpdate) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/Flow/Nodes/IntNode.swift b/Sources/Flow/Nodes/IntNode.swift new file mode 100644 index 0000000..4df2de5 --- /dev/null +++ b/Sources/Flow/Nodes/IntNode.swift @@ -0,0 +1,64 @@ +// +// IntNode.swift +// +// +// Created by Alessio Nossa on 20/04/2023. +// + +import SwiftUI + +public class IntNode: BaseNode { + + struct IntMiddleView: View { + @ObservedObject var node: IntNode + + var valueBinding: Binding { + Binding( + get: { + guard let value = node.valueUpdate?.value else { return "" } + return String(value) + }, + set: { newValue in + self.node.valueUpdate = ValueUpdate(Int.init(newValue)) + } + ) + } + + var body: some View { + TextField("Integer", text: valueBinding) +#if os(iOS) + .keyboardType(.numberPad) +#endif + .textFieldStyle(.roundedBorder) + .frame(width: 100) + } + } + +// @Published var value: Int? = nil + @Published var valueUpdate: ValueUpdate? = nil + + override public init(name: String, position: CGPoint? = nil) { + super.init(name: name, position: position) + + inputs = [ + Port(name: "Value", type: .input, valueType: Int.self, parentNodeId: id) + ] + + outputs = [ + Port(name: "Value", type: .output, valueType: Int.self, parentNodeId: id) + ] + + titleBarColor = .brown + + middleView = AnyView(IntMiddleView(node: self)) + + if let intInput = inputs[0] as? Port { +// intInput.$value.assign(to: &$value) + intInput.$valueUpdate.assign(to: &$valueUpdate) + } + + if let intOutput = outputs[0] as? Port { + $valueUpdate.assign(to: &intOutput.$valueUpdate) + } + } +} diff --git a/Sources/Flow/Nodes/TriggerButtonNode.swift b/Sources/Flow/Nodes/TriggerButtonNode.swift new file mode 100644 index 0000000..840f807 --- /dev/null +++ b/Sources/Flow/Nodes/TriggerButtonNode.swift @@ -0,0 +1,40 @@ +// +// TriggerButtonNode.swift +// +// +// Created by Alessio Nossa on 20/04/2023. +// + +import Foundation + +public class TriggerButtonNode: BaseNode { + + struct TriggerMiddleView: View { + @ObservedObject var node: TriggerButtonNode + + var body: some View { + Button("Action!", action: { + node.trigger.send(ValueUpdate(())) + }) + .frame(width: 100) + } + } + + var trigger = PassthroughSubject?, Never>() + + override public init(name: String, position: CGPoint? = nil) { + super.init(name: name, position: position) + + outputs = [ + Port(name: "Trigger", type: .output, valueType: Void.self, parentNodeId: id) + ] + + titleBarColor = .brown + + middleView = AnyView(TriggerMiddleView(node: self)) + + if let intOutput = outputs[0] as? Flow.Port { + trigger.assign(to: &intOutput.$valueUpdate) + } + } +} From 25b6f7acd7ef6cb271379da9bd49d934186647bd Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Thu, 20 Apr 2023 02:06:39 +0200 Subject: [PATCH 13/22] Comment unused code --- Sources/Flow/Views/NodeEditor+Gestures.swift | 56 ++++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Sources/Flow/Views/NodeEditor+Gestures.swift b/Sources/Flow/Views/NodeEditor+Gestures.swift index 2d3d6c4..d5660e5 100644 --- a/Sources/Flow/Views/NodeEditor+Gestures.swift +++ b/Sources/Flow/Views/NodeEditor+Gestures.swift @@ -11,33 +11,33 @@ extension NodeEditor { case none } -#if os(macOS) - var commandGesture: some Gesture { - DragGesture(minimumDistance: 0).modifiers(.command).onEnded { drag in - guard drag.distance < 5 else { return } - - let startLocation = toLocal(drag.startLocation) - - let hitResult = patch.hitTest(point: startLocation, layout: layout) - switch hitResult { - case .none: - return - case let .node(nodeIndex): - if selection.contains(nodeIndex) { - selection.remove(nodeIndex) - } else { - selection.insert(nodeIndex) - } - default: break - } - } - } -#endif +//#if os(macOS) +// var commandGesture: some Gesture { +// DragGesture(minimumDistance: 0).modifiers(.command).onEnded { drag in +// guard drag.distance < 5 else { return } +// +// let startLocation = toLocal(drag.startLocation) +// +// let hitResult = patch.hitTest(point: startLocation, layout: layout) +// switch hitResult { +// case .none: +// return +// case let .node(nodeIndex): +// if selection.contains(nodeIndex) { +// selection.remove(nodeIndex) +// } else { +// selection.insert(nodeIndex) +// } +// default: break +// } +// } +// } +//#endif } -extension DragGesture.Value { - @inlinable @inline(__always) - var distance: CGFloat { - startLocation.distance(to: location) - } -} +//extension DragGesture.Value { +// @inlinable @inline(__always) +// var distance: CGFloat { +// startLocation.distance(to: location) +// } +//} From 6da1260bf82f09c7c99367e9abe610eec583fe50 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Wed, 10 May 2023 12:53:55 +0200 Subject: [PATCH 14/22] Remove green background on node editor --- Sources/Flow/Views/NodeEditor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Flow/Views/NodeEditor.swift b/Sources/Flow/Views/NodeEditor.swift index 0602f60..1782b5d 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -73,7 +73,7 @@ public struct NodeEditor: View { self.drawWires(cx: cx) self.drawDraggedWire(cx: cx) self.drawSelectionRect(cx: cx) - }.background(.green) + } ForEach(patch.nodes, id: \.id) { node in NodeView(node: node, gestureState: $dragInfo) From 2467bce2f78b3aa776febba6adf02f950d58179e Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Wed, 10 May 2023 13:03:13 +0200 Subject: [PATCH 15/22] Improve UI --- Sources/Flow/Model/LayoutConstants.swift | 2 ++ Sources/Flow/Nodes/IntNode.swift | 2 +- Sources/Flow/Nodes/TriggerButtonNode.swift | 10 ++++--- Sources/Flow/Views/ConnectorView.swift | 12 ++------ Sources/Flow/Views/NodeEditor+Drawing.swift | 31 +++++++++++++++++++++ Sources/Flow/Views/NodeEditor.swift | 21 +++++++++++--- Sources/Flow/Views/NodeView.swift | 8 +++--- 7 files changed, 64 insertions(+), 22 deletions(-) diff --git a/Sources/Flow/Model/LayoutConstants.swift b/Sources/Flow/Model/LayoutConstants.swift index 66d2201..1ef9000 100644 --- a/Sources/Flow/Model/LayoutConstants.swift +++ b/Sources/Flow/Model/LayoutConstants.swift @@ -13,6 +13,8 @@ public struct LayoutConstants { public var nodeTitleFont = Font.title public var portNameFont = Font.caption public var nodeCornerRadius: CGFloat = 5 + public var backgroundlinesSpacing: CGFloat = 50 + public var backgroundlinesPattern: [CGFloat] = [4,4] public init() {} } diff --git a/Sources/Flow/Nodes/IntNode.swift b/Sources/Flow/Nodes/IntNode.swift index 4df2de5..b258e17 100644 --- a/Sources/Flow/Nodes/IntNode.swift +++ b/Sources/Flow/Nodes/IntNode.swift @@ -48,7 +48,7 @@ public class IntNode: BaseNode { Port(name: "Value", type: .output, valueType: Int.self, parentNodeId: id) ] - titleBarColor = .brown + titleBarColor = Color(UIColor.systemMint) middleView = AnyView(IntMiddleView(node: self)) diff --git a/Sources/Flow/Nodes/TriggerButtonNode.swift b/Sources/Flow/Nodes/TriggerButtonNode.swift index 840f807..59d0098 100644 --- a/Sources/Flow/Nodes/TriggerButtonNode.swift +++ b/Sources/Flow/Nodes/TriggerButtonNode.swift @@ -5,7 +5,8 @@ // Created by Alessio Nossa on 20/04/2023. // -import Foundation +import SwiftUI +import Combine public class TriggerButtonNode: BaseNode { @@ -16,7 +17,8 @@ public class TriggerButtonNode: BaseNode { Button("Action!", action: { node.trigger.send(ValueUpdate(())) }) - .frame(width: 100) + .buttonStyle(.borderedProminent) + .frame(width: 100) } } @@ -29,11 +31,11 @@ public class TriggerButtonNode: BaseNode { Port(name: "Trigger", type: .output, valueType: Void.self, parentNodeId: id) ] - titleBarColor = .brown + titleBarColor = Color(UIColor.systemOrange) middleView = AnyView(TriggerMiddleView(node: self)) - if let intOutput = outputs[0] as? Flow.Port { + if let intOutput = outputs[0] as? Port { trigger.assign(to: &intOutput.$valueUpdate) } } diff --git a/Sources/Flow/Views/ConnectorView.swift b/Sources/Flow/Views/ConnectorView.swift index f4bca33..03c9090 100644 --- a/Sources/Flow/Views/ConnectorView.swift +++ b/Sources/Flow/Views/ConnectorView.swift @@ -42,11 +42,7 @@ struct ConnectorView: View { @State private var draggingWire: Bool = false var body: some View { - HStack(spacing: 4) { - if connector.type == .output { - Text(connector.name) - .font(.caption) - } + VStack(spacing: 4) { ZStack(alignment: .center) { GeometryReader { proxy in @@ -72,10 +68,8 @@ struct ConnectorView: View { .scaleEffect((isDragging || isPossibleInput) ? 1.2 : 1.0) .gesture(dragGesture) - if connector.type == .input { - Text(connector.name) - .font(.caption) - } + Text(connector.name) + .font(.caption) } .animation(.easeInOut, value: isDragging) .animation(.easeInOut, value: isPossibleInput) diff --git a/Sources/Flow/Views/NodeEditor+Drawing.swift b/Sources/Flow/Views/NodeEditor+Drawing.swift index de0abb7..c1a9cad 100644 --- a/Sources/Flow/Views/NodeEditor+Drawing.swift +++ b/Sources/Flow/Views/NodeEditor+Drawing.swift @@ -24,6 +24,16 @@ extension GraphicsContext { style: StrokeStyle(lineWidth: 2.0, lineCap: .round) ) } + + func strokeDashedLine(from startPoint: CGPoint, to endPoint: CGPoint, pattern: [CGFloat]) { + var path = Path() + path.move(to: startPoint) + path.addLine(to: endPoint) + + let strokeStyle = StrokeStyle(lineWidth: 1, dash: pattern) + + stroke(path, with: .color(Color.gray.opacity(0.2)), style: strokeStyle) + } } extension NodeEditor { @@ -44,6 +54,27 @@ extension NodeEditor { cx.strokeWire(from: fromPoint, to: toPoint, gradient: gradient) } } + + func drawDashedBackgroundLines(_ cx: GraphicsContext, _ size: CGSize) { + let width = size.width + let height = size.height + + // Draw vertical lines + for x in stride(from: 0, to: width, by: layout.backgroundlinesSpacing) { + let startPoint = CGPoint(x: x, y: 0) + let endPoint = CGPoint(x: x, y: height) + + cx.strokeDashedLine(from: startPoint, to: endPoint, pattern: layout.backgroundlinesPattern) + } + + // Draw horizontal lines + for y in stride(from: 0, to: height, by: layout.backgroundlinesSpacing) { + let startPoint = CGPoint(x: 0, y: y) + let endPoint = CGPoint(x: width, y: y) + + cx.strokeDashedLine(from: startPoint, to: endPoint, pattern: layout.backgroundlinesPattern) + } + } func drawDraggedWire(cx: GraphicsContext) { if case let .wire(output: output, offset: offset, _, _) = dragInfo { diff --git a/Sources/Flow/Views/NodeEditor.swift b/Sources/Flow/Views/NodeEditor.swift index 1782b5d..fcc5cb7 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -18,6 +18,8 @@ public struct NodeEditor: View { /// State for all gestures. @GestureState var dragInfo = DragInfo.none + + @State var backgroundColor: Color /// Node moved handler closure. public typealias NodeMovedHandler = (_ index: NodeId, @@ -40,10 +42,12 @@ public struct NodeEditor: View { /// - patch: Patch to display. /// - selection: Set of nodes currently selected. public init(patch: Patch, + backgroundColor: Color = .clear, selection: Binding>, layout: LayoutConstants = LayoutConstants()) { self.patch = patch + self._backgroundColor = .init(initialValue: backgroundColor) _selection = selection self.layout = layout } @@ -61,15 +65,14 @@ public struct NodeEditor: View { public var body: some View { GeometryReader { geometryProxy in ScrollViewReader { scrollProxy in - ScrollView([.horizontal, .vertical], showsIndicators: false) { + ScrollView([.horizontal, .vertical], showsIndicators: true) { ZStack { - Color.clear + self.backgroundColor .frame(width: 2000, height: 2000) - - Canvas { cx, size in + self.drawDashedBackgroundLines(cx, size) self.drawWires(cx: cx) self.drawDraggedWire(cx: cx) self.drawSelectionRect(cx: cx) @@ -77,9 +80,19 @@ public struct NodeEditor: View { ForEach(patch.nodes, id: \.id) { node in NodeView(node: node, gestureState: $dragInfo) + .id(node.id) .fixedSize() } + } + .onReceive(patch.$nodeToShow, perform: { nodeToShow in + if let nodeToShow { + withAnimation { + scrollProxy.scrollTo(nodeToShow.id) +// patch.nodeToShow = nil + } + } + }) .coordinateSpace(name: NodeEditor.kEditorCoordinateSpaceName) } diff --git a/Sources/Flow/Views/NodeView.swift b/Sources/Flow/Views/NodeView.swift index c2bcf70..34508b2 100644 --- a/Sources/Flow/Views/NodeView.swift +++ b/Sources/Flow/Views/NodeView.swift @@ -45,11 +45,11 @@ struct NodeView: View { } .frame(maxWidth: .infinity) .padding(.vertical, 16) - .background(Color.blue) + .background(node.titleBarColor) .gesture(dragGesture) HStack(alignment: .center, spacing: 8) { - VStack { + VStack(alignment: .center) { ForEach(node.inputs, id: \.id) { input in ConnectorView(connector: input, gestureState: gestureState) } @@ -60,7 +60,7 @@ struct NodeView: View { AnyView(middleView) } - VStack(alignment: .trailing) { + VStack(alignment: .center) { ForEach(node.outputs, id: \.id) { output in ConnectorView(connector: output, gestureState: gestureState) } @@ -76,7 +76,7 @@ struct NodeView: View { .opacity(0.6) ) #endif -#if canImport(AppKit) +#if os(macOS) .background( Color(NSColor.textBackgroundColor) .opacity(0.6) From 8fbc0e11e96f1d2d798cc9d9c8a8363001db38a6 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Wed, 10 May 2023 13:03:35 +0200 Subject: [PATCH 16/22] Improvements to Patch --- Sources/Flow/Model/Patch.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/Flow/Model/Patch.swift b/Sources/Flow/Model/Patch.swift index bbf14b7..3e08518 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -27,7 +27,9 @@ public class Patch: ObservableObject { @Published public var nodes: [BaseNode] - @Published var wires: Set + @Published private(set) var wires: Set + + @Published var nodeToShow: BaseNode? public init(nodes: [BaseNode], wires: Set) { self.nodes = nodes @@ -40,6 +42,14 @@ public class Patch: ObservableObject { observeNotes() } + func reset() { + self.wires = Set() + self.nodes = [] + self.nodesCancellables.forEach { cancellable in + cancellable.cancel() + } + } + private var nodesCancellables: Set = [] private func observeNotes() { From 9c94ac029c4b24e9b694381eef7162f2a8bf5bc7 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Wed, 10 May 2023 13:04:01 +0200 Subject: [PATCH 17/22] Implement StringNode --- Sources/Flow/Nodes/StringNode.swift | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 Sources/Flow/Nodes/StringNode.swift diff --git a/Sources/Flow/Nodes/StringNode.swift b/Sources/Flow/Nodes/StringNode.swift new file mode 100644 index 0000000..8d290a9 --- /dev/null +++ b/Sources/Flow/Nodes/StringNode.swift @@ -0,0 +1,49 @@ +// +// StringNode.swift +// +// +// Created by Alessio Nossa on 20/04/2023. +// + +import SwiftUI +import Combine + +public class StringNode: BaseNode { + + struct StringMiddleView: View { + @ObservedObject var node: StringNode + + var body: some View { + Text("\(node.valueUpdate?.value ?? "")") + .font(.callout.monospaced()) + .lineLimit(nil) + .frame(width: 150) + } + } + + @Published var valueUpdate: ValueUpdate? = nil + + override public init(name: String, position: CGPoint? = nil) { + super.init(name: name, position: position) + + inputs = [ + Port(name: "Value", type: .input, valueType: String.self, parentNodeId: id) + ] + + outputs = [ + Port(name: "Value", type: .output, valueType: String.self, parentNodeId: id) + ] + + titleBarColor = Color(UIColor.systemTeal) + + middleView = AnyView(StringMiddleView(node: self)) + + if let intInput = inputs[0] as? Port { + intInput.$valueUpdate.assign(to: &$valueUpdate) + } + + if let intOutput = outputs[0] as? Port { + $valueUpdate.assign(to: &intOutput.$valueUpdate) + } + } +} From 4678381c6e3769670fd1ebb3a0f3b43104f01c21 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Wed, 10 May 2023 13:32:53 +0200 Subject: [PATCH 18/22] Fix colours and imports on macOS --- Sources/Flow/Nodes/IntNode.swift | 10 ++++++++++ Sources/Flow/Nodes/StringNode.swift | 9 +++++++++ Sources/Flow/Nodes/TriggerButtonNode.swift | 9 +++++++++ 3 files changed, 28 insertions(+) diff --git a/Sources/Flow/Nodes/IntNode.swift b/Sources/Flow/Nodes/IntNode.swift index b258e17..b5f60cf 100644 --- a/Sources/Flow/Nodes/IntNode.swift +++ b/Sources/Flow/Nodes/IntNode.swift @@ -6,6 +6,11 @@ // import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif public class IntNode: BaseNode { @@ -48,7 +53,12 @@ public class IntNode: BaseNode { Port(name: "Value", type: .output, valueType: Int.self, parentNodeId: id) ] + #if canImport(UIKit) titleBarColor = Color(UIColor.systemMint) + #elseif canImport(AppKit) + titleBarColor = Color(NSColor.systemMint) + #endif + middleView = AnyView(IntMiddleView(node: self)) diff --git a/Sources/Flow/Nodes/StringNode.swift b/Sources/Flow/Nodes/StringNode.swift index 8d290a9..608edab 100644 --- a/Sources/Flow/Nodes/StringNode.swift +++ b/Sources/Flow/Nodes/StringNode.swift @@ -7,6 +7,11 @@ import SwiftUI import Combine +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif public class StringNode: BaseNode { @@ -34,7 +39,11 @@ public class StringNode: BaseNode { Port(name: "Value", type: .output, valueType: String.self, parentNodeId: id) ] + #if canImport(UIKit) titleBarColor = Color(UIColor.systemTeal) + #elseif canImport(AppKit) + titleBarColor = Color(NSColor.systemTeal) + #endif middleView = AnyView(StringMiddleView(node: self)) diff --git a/Sources/Flow/Nodes/TriggerButtonNode.swift b/Sources/Flow/Nodes/TriggerButtonNode.swift index 59d0098..a03d91d 100644 --- a/Sources/Flow/Nodes/TriggerButtonNode.swift +++ b/Sources/Flow/Nodes/TriggerButtonNode.swift @@ -7,6 +7,11 @@ import SwiftUI import Combine +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif public class TriggerButtonNode: BaseNode { @@ -31,7 +36,11 @@ public class TriggerButtonNode: BaseNode { Port(name: "Trigger", type: .output, valueType: Void.self, parentNodeId: id) ] + #if canImport(UIKit) titleBarColor = Color(UIColor.systemOrange) + #elseif canImport(AppKit) + titleBarColor = Color(NSColor.systemOrange) + #endif middleView = AnyView(TriggerMiddleView(node: self)) From 2d82a4cb86695390531f75f4ebcfc7cd0aa52706 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Sun, 25 Feb 2024 20:24:02 +0100 Subject: [PATCH 19/22] Improve demo project coverage --- Demo/Shared/ContentView.swift | 34 ++++++++++++++++++++++++++--- Sources/Flow/Nodes/StringNode.swift | 4 ++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index b59308d..46b2843 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -44,8 +44,18 @@ struct ContentView: View { @StateObject var patch = simplePatch() @State var selection = Set() - func addNode() { - let newNode = IntNode(name: "Integer") + func addNode(type: DemoNodeType) { + let newNode: BaseNode + switch type { + case .integer: + newNode = IntNode(name: "Integer") + case .string: + let stringNode = StringNode(name: "") + stringNode.setValue("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent at tortor egestas ante ultricies lobortis. Cras fringilla, turpis id volutpat mollis, ligula metus egestas ante, sed fringilla ex sapien in elit.") + newNode = stringNode + case .trigger: + newNode = TriggerButtonNode(name: "Trigger") + } patch.nodes.append(newNode) } @@ -58,8 +68,26 @@ struct ContentView: View { .onWireRemoved { wire in print("Removed wire: \(wire)") } - Button("Add Node", action: addNode).padding() + + Menu("Add node") { + Button(action: { addNode(type: .integer) }) { + Label("Add Integer Node", systemImage: "number") + } + + Button(action: { addNode(type: .string) }) { + Label("Add Text Node", systemImage: "textformat") + } + + Button(action: { addNode(type: .trigger) }) { + Label("Add Trigger Node", systemImage: "button.programmable") + } + } + .padding() } } } + +enum DemoNodeType { + case integer, string, trigger +} diff --git a/Sources/Flow/Nodes/StringNode.swift b/Sources/Flow/Nodes/StringNode.swift index 608edab..6f9563f 100644 --- a/Sources/Flow/Nodes/StringNode.swift +++ b/Sources/Flow/Nodes/StringNode.swift @@ -55,4 +55,8 @@ public class StringNode: BaseNode { $valueUpdate.assign(to: &intOutput.$valueUpdate) } } + + public func setValue(_ newString: String) { + self.valueUpdate = .init(newString) + } } From 863546e66cb9ec1c7eac76719081fe0f237357f8 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Sun, 25 Feb 2024 20:24:32 +0100 Subject: [PATCH 20/22] Allow changes in StringNode --- Sources/Flow/Nodes/StringNode.swift | 32 +++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/Flow/Nodes/StringNode.swift b/Sources/Flow/Nodes/StringNode.swift index 6f9563f..72557c3 100644 --- a/Sources/Flow/Nodes/StringNode.swift +++ b/Sources/Flow/Nodes/StringNode.swift @@ -18,11 +18,35 @@ public class StringNode: BaseNode { struct StringMiddleView: View { @ObservedObject var node: StringNode + var valueBinding: Binding { + Binding( + get: { + return node.valueUpdate?.value ?? "" + }, + set: { newValue in + self.node.valueUpdate = ValueUpdate(newValue) + } + ) + } + var body: some View { - Text("\(node.valueUpdate?.value ?? "")") - .font(.callout.monospaced()) - .lineLimit(nil) - .frame(width: 150) + if #available(iOS 16.0, macOS 13.0, *) { + TextField("Text", text: valueBinding, axis: .vertical) + .multilineTextAlignment(.leading) + .lineLimit(2...6) + .font(.callout.monospaced()) + .textFieldStyle(.roundedBorder) + .frame(width: 192) + } else { + TextEditor(text: valueBinding) + .frame(minHeight: 40, maxHeight: 80) + .multilineTextAlignment(.leading) + .font(.callout.monospaced()) + .textFieldStyle(.roundedBorder) + .frame(width: 192) + .fixedSize(horizontal: true, vertical: true) + } + } } From 4993498c6bba958679c3c20f5892b5477980e771 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Sun, 25 Feb 2024 20:25:15 +0100 Subject: [PATCH 21/22] Improve layout of NodeView --- Sources/Flow/Views/NodeView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/Flow/Views/NodeView.swift b/Sources/Flow/Views/NodeView.swift index 34508b2..68f6282 100644 --- a/Sources/Flow/Views/NodeView.swift +++ b/Sources/Flow/Views/NodeView.swift @@ -38,11 +38,14 @@ struct NodeView: View { var body: some View { GeometryReader { geometryProxy in - VStack { + VStack(spacing: 0) { HStack { Text(node.name) .font(.headline) + .lineLimit(nil) + .padding(.horizontal, 8) } + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) .padding(.vertical, 16) .background(node.titleBarColor) @@ -67,8 +70,9 @@ struct NodeView: View { } .padding(.trailing, 8) } + .fixedSize(horizontal: false, vertical: true) .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } #if canImport(UIKit) .background( @@ -83,9 +87,10 @@ struct NodeView: View { ) #endif .cornerRadius(8) + .frame(maxWidth: 500) .shadow(radius: 5) .scaleEffect(dragging ? 1.1 : 1.0) - .fixedSize() + .fixedSize(horizontal: true, vertical: false) .position(currentNodePosition ?? .zero) .animation(.easeInOut, value: dragging) .onAppear { From 39f814e47a5c01dbcc548e91f32521c69cfc1a42 Mon Sep 17 00:00:00 2001 From: Alessio Nossa Date: Sun, 25 Feb 2024 20:28:14 +0100 Subject: [PATCH 22/22] Add current position input result indicator --- Sources/Flow/Model/Port.swift | 6 ++ Sources/Flow/Views/ConnectorView.swift | 59 +++++++++++++++++--- Sources/Flow/Views/NodeEditor+Drawing.swift | 4 +- Sources/Flow/Views/NodeEditor+Gestures.swift | 2 +- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/Sources/Flow/Model/Port.swift b/Sources/Flow/Model/Port.swift index 845041b..5205876 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -139,6 +139,12 @@ public class Port: Identifiable, ObservableObject, PortProtocol { self.nodeId = parentNodeId } + /// Determines wether or not a port can connect to this port. Default implementation returns `true` if + /// the ports are of the same type. + /// + /// The implementation can be overridden if the port is able to accept also other type of inputs. + /// - Parameter port: The ports that wants to connect to the istance. + /// - Returns: `true` if the istance can accept input from `port` public func canConnectTo(port: any PortProtocol) -> Bool { if port is Port { return true diff --git a/Sources/Flow/Views/ConnectorView.swift b/Sources/Flow/Views/ConnectorView.swift index 03c9090..33caf31 100644 --- a/Sources/Flow/Views/ConnectorView.swift +++ b/Sources/Flow/Views/ConnectorView.swift @@ -15,20 +15,28 @@ struct ConnectorView: View { var gestureState: GestureState var isDragging: Bool { - if case let NodeEditor.DragInfo.wire(outputId, _, _, _) = gestureState.wrappedValue { + if case let NodeEditor.DragInfo.wire(outputId, _, _, _, _) = gestureState.wrappedValue { return (outputId.portId == connector.id) && (outputId.nodeId == connector.nodeId) } return false } var isPossibleInput: Bool { - if case let NodeEditor.DragInfo.wire(_, _, _, possibleInputId) = gestureState.wrappedValue, + if case let NodeEditor.DragInfo.wire(_, _, _, possibleInputId, _) = gestureState.wrappedValue, let possibleInputId { return (possibleInputId.portId == connector.id) && (possibleInputId.nodeId == connector.nodeId) } return false } + var isCurrentPositinInput: Bool { + if case let NodeEditor.DragInfo.wire(_, _, _, _, currentPositionInputId) = gestureState.wrappedValue, + let currentPositionInputId { + return (currentPositionInputId.portId == connector.id) && (currentPositionInputId.nodeId == connector.nodeId) + } + return false + } + var isConnected: Bool { switch connector.type { case .input: @@ -47,7 +55,7 @@ struct ConnectorView: View { ZStack(alignment: .center) { GeometryReader { proxy in Circle() - .fill(Color.red) + .fill(Color.mint) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { connector.frame = proxy.frame(in: .named(NodeEditor.kEditorCoordinateSpaceName)) @@ -64,6 +72,12 @@ struct ConnectorView: View { } } + .overlay { + if isCurrentPositinInput { + Circle() + .fill(isPossibleInput ? .green : .red) + } + } .frame(width: 20, height: 20) .scaleEffect((isDragging || isPossibleInput) ? 1.2 : 1.0) .gesture(dragGesture) @@ -74,6 +88,7 @@ struct ConnectorView: View { .animation(.easeInOut, value: isDragging) .animation(.easeInOut, value: isPossibleInput) .animation(.easeInOut, value: isConnected) + .animation(.easeInOut, value: isCurrentPositinInput) } var dragGesture: some Gesture { @@ -90,20 +105,25 @@ struct ConnectorView: View { // (inputCenter - outputCenter) + dragValue.translation - (inputCenter - dragValue.startLocation) let originDifference = connectorFrame.center - dragValue.startLocation let offset = (connectorFrame.center - outputFrame.center) + dragValue.translation - originDifference - let possibleInputPortId = findPossibleInputPortId(outputFrame: outputFrame, offset: offset) + let currentPositionInput = inputPort(in: outputFrame, offset: offset) + let possibleInputPortId = findPossibleInputPortId(inputPort: currentPositionInput) + let currentPositionInputId = inputPortId(inputPort: currentPositionInput) dragState = .wire(output: attachedWire.output, offset: offset, hideWire: attachedWire, - possibleInputId: possibleInputPortId) + possibleInputId: possibleInputPortId, + currentPositionInputId: currentPositionInputId) case .output: let outputId = OutputID(connector.nodeId, connector.id) guard let connectorFrame = connector.frame else { return } let originDifference = connectorFrame.center - dragValue.startLocation let offset = dragValue.translation - originDifference - let possibleInputPortId = findPossibleInputPortId(outputFrame: connectorFrame, offset: offset) + let currentPositionInput = inputPort(in: connectorFrame, offset: offset) + let possibleInputPortId = findPossibleInputPortId(inputPort: currentPositionInput) + let currentPositionInputId = inputPortId(inputPort: currentPositionInput) - dragState = NodeEditor.DragInfo.wire(output: outputId, offset: offset, possibleInputId: possibleInputPortId) + dragState = NodeEditor.DragInfo.wire(output: outputId, offset: offset, possibleInputId: possibleInputPortId, currentPositionInputId: currentPositionInputId) } }) .onEnded { value in @@ -141,7 +161,7 @@ struct ConnectorView: View { } } - func findPossibleInputPort(outputFrame: CGRect, offset: CGSize) -> (any PortProtocol)? { + func inputPort(in outputFrame: CGRect, offset: CGSize) -> (any PortProtocol)? { let point = outputFrame.center + offset var inputPort: (any PortProtocol)? _ = patch.nodes.reversed().first { node in @@ -151,6 +171,29 @@ struct ConnectorView: View { return inputPort != nil } + return inputPort + } + + func inputPortId(inputPort: (any PortProtocol)?) -> InputID? { + guard let inputPort else { return nil } + return InputID(inputPort.nodeId, inputPort.id) + } + + func findPossibleInputPort(inputPort: (any PortProtocol)?) -> (any PortProtocol)? { + guard let inputPort, inputPort.canConnectTo(port: connector) else { return nil } + return inputPort + } + + func findPossibleInputPortId(inputPort: (any PortProtocol)?) -> InputID? { + guard let possibleInputPort = findPossibleInputPort(inputPort: inputPort) else { + return nil + } + return InputID(possibleInputPort.nodeId, possibleInputPort.id) + } + + func findPossibleInputPort(outputFrame: CGRect, offset: CGSize) -> (any PortProtocol)? { + let inputPort = inputPort(in: outputFrame, offset: offset) + guard let inputPort, inputPort.canConnectTo(port: connector) else { return nil } return inputPort } diff --git a/Sources/Flow/Views/NodeEditor+Drawing.swift b/Sources/Flow/Views/NodeEditor+Drawing.swift index c1a9cad..25bdca4 100644 --- a/Sources/Flow/Views/NodeEditor+Drawing.swift +++ b/Sources/Flow/Views/NodeEditor+Drawing.swift @@ -41,7 +41,7 @@ extension NodeEditor { func drawWires(cx: GraphicsContext) { var hideWire: Wire? switch dragInfo { - case let .wire(_, _, hideWire: hw, _): + case let .wire(_, _, hideWire: hw, _, _): hideWire = hw default: hideWire = nil @@ -77,7 +77,7 @@ extension NodeEditor { } func drawDraggedWire(cx: GraphicsContext) { - if case let .wire(output: output, offset: offset, _, _) = dragInfo { + if case let .wire(output: output, offset: offset, _, _, _) = dragInfo { guard let fromPoint = self.patch.nodes[portId: output].frame?.center else { return } let gradient = self.gradient(for: output) diff --git a/Sources/Flow/Views/NodeEditor+Gestures.swift b/Sources/Flow/Views/NodeEditor+Gestures.swift index d5660e5..0160b3c 100644 --- a/Sources/Flow/Views/NodeEditor+Gestures.swift +++ b/Sources/Flow/Views/NodeEditor+Gestures.swift @@ -5,7 +5,7 @@ import SwiftUI extension NodeEditor { /// State for all gestures. enum DragInfo: Equatable { - case wire(output: OutputID, offset: CGSize = .zero, hideWire: Wire? = nil, possibleInputId: InputID? = nil) + case wire(output: OutputID, offset: CGSize = .zero, hideWire: Wire? = nil, possibleInputId: InputID? = nil, currentPositionInputId: InputID? = nil) case node(id: NodeId, offset: CGSize = .zero) case selection(rect: CGRect = .zero) case none