diff --git a/Demo/Shared/ContentView.swift b/Demo/Shared/ContentView.swift index 86532f3..46b2843 100644 --- a/Demo/Shared/ContentView.swift +++ b/Demo/Shared/ContentView.swift @@ -1,57 +1,93 @@ import Flow import SwiftUI +import Combine -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 nodes = [generator, processor, generator, processor, mixer, output] - 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))]) - - var patch = Patch(nodes: nodes, wires: wires) - patch.recursiveLayout(nodeIndex: 5, at: CGPoint(x: 800, y: 50)) +func simplePatch() -> Patch { + let int1 = IntNode(name: "Integer 1") + let int2 = IntNode(name: "Integer 2") + + 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: 600, 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: [BaseNode] = [] + 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 = [] 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) } 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"]) + 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) } var body: some View { ZStack(alignment: .topTrailing) { - NodeEditor(patch: $patch, selection: $selection) - Button("Add Node", action: addNode).padding() + NodeEditor(patch: patch, selection: $selection) + .onWireAdded { wire in + print("Added wire: \(wire)") + } + .onWireRemoved { wire in + print("Removed wire: \(wire)") + } + + 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/Flow.playground/Contents.swift b/Flow.playground/Contents.swift index c52cd0d..b37d11e 100644 --- a/Flow.playground/Contents.swift +++ b/Flow.playground/Contents.swift @@ -3,44 +3,26 @@ import PlaygroundSupport import SwiftUI func simplePatch() -> Patch { - let midiSource = Node(name: "MIDI source", - outputs: [ - Port(name: "out ch. 1", type: .midi), - Port(name: "out ch. 2", type: .midi), - ]) - let generator = 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 nodes = [midiSource, generator, processor, generator, processor, mixer, output] - + let int1 = IntNode(name: "Integer 1") + let int2 = IntNode(name: "Integer 2") + + let nodes = [int1, int2] + 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(int1, \.[0]), to: InputID(int2, \.[0])) ]) - - var patch = Patch(nodes: nodes, wires: wires) - patch.recursiveLayout(nodeIndex: 6, at: CGPoint(x: 1000, y: 50)) + + let patch = Patch(nodes: nodes, wires: wires) + patch.recursiveLayout(nodeId: int2.id, at: CGPoint(x: 600, 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/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/Model/Node+Gestures.swift b/Sources/Flow/Model/Node+Gestures.swift deleted file mode 100644 index eef5ce8..0000000 --- a/Sources/Flow/Model/Node+Gestures.swift +++ /dev/null @@ -1,32 +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) -> Node { - var result = self - result.position.x += offset.width - result.position.y += offset.height - return result - } - - func hitTest(nodeIndex: Int, point: CGPoint, layout: LayoutConstants) -> Patch.HitTestResult? { - for (inputIndex, _) in inputs.enumerated() { - if inputRect(input: inputIndex, layout: layout).contains(point) { - return .input(nodeIndex, inputIndex) - } - } - for (outputIndex, _) in outputs.enumerated() { - if outputRect(output: outputIndex, layout: layout).contains(point) { - return .output(nodeIndex, outputIndex) - } - } - - if rect(layout: layout).contains(point) { - return .node(nodeIndex) - } - - return nil - } -} diff --git a/Sources/Flow/Model/Node+Layout.swift b/Sources/Flow/Model/Node+Layout.swift index f042e42..0230d88 100644 --- a/Sources/Flow/Model/Node+Layout.swift +++ b/Sources/Flow/Model/Node+Layout.swift @@ -6,24 +6,11 @@ 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)) 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 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 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 4d42b5d..96206aa 100644 --- a/Sources/Flow/Model/Node.swift +++ b/Sources/Flow/Model/Node.swift @@ -2,54 +2,147 @@ import CoreGraphics import SwiftUI +import Combine /// 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 var name: String - public var position: CGPoint - public var titleBarColor: Color +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? - public var locked = false - - public var inputs: [Port] - public var outputs: [Port] - - @_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 - } - - public init(name: String, - position: CGPoint = .zero, - titleBarColor: Color = Color.clear, - locked: Bool = false, - inputs: [String] = [], - outputs: [String] = []) - { + var locked: Bool { get set } + + var inputs: [any PortProtocol] { get } + var middleView: AnyView? { get } + var outputs: [any PortProtocol] { get } + + func indexOfOutput(_ port: OutputID) -> Array.Index? + + func indexOfInput(_ port: InputID) -> Array.Index? +} + +extension Node { + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public func indexOfOutput(_ port: OutputID) -> Array.Index? { + self.outputs.firstIndex { $0.id == port.portId } + } + + public func indexOfInput(_ port: InputID) -> Array.Index? { + self.inputs.firstIndex { $0.id == port.portId } + } +} + +open class BaseNode: Node, ObservableObject { + + public var id: NodeId = UUID() + + public var name: String + + @Published public var position: CGPoint? + + @Published public var frame: CGRect? + + public var titleBarColor: Color = .mint + + public var locked: Bool = false + + @Published open var inputs: [any PortProtocol] = [] + + @Published open var outputs: [any PortProtocol] = [] + + 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 - self.titleBarColor = titleBarColor - self.locked = locked - self.inputs = inputs.map { Port(name: $0) } - self.outputs = outputs.map { Port(name: $0) } + + 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: Node { + subscript(withId id: NodeId) -> Element { + get { + guard let node = first(where: { $0.id == id }) else { + fatalError("Node with identifier \(id.uuidString) not found") + } + + 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] + } } } diff --git a/Sources/Flow/Model/Patch+Gestures.swift b/Sources/Flow/Model/Patch+Gestures.swift deleted file mode 100644 index 1394727..0000000 --- a/Sources/Flow/Model/Patch+Gestures.swift +++ /dev/null @@ -1,45 +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(NodeIndex) - case input(NodeIndex, PortIndex) - case output(NodeIndex, PortIndex) - } - - /// 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) { - return result - } - } - - return nil - } - - mutating func moveNode( - nodeIndex: NodeIndex, - offset: CGSize, - nodeMoved: NodeEditor.NodeMovedHandler - ) { - if !nodes[nodeIndex].locked { - nodes[nodeIndex].position += offset - nodeMoved(nodeIndex, nodes[nodeIndex].position) - } - } - - func selected(in rect: CGRect, layout: LayoutConstants) -> Set { - var selection = Set() - - for (idx, node) in nodes.enumerated() { - if rect.intersects(node.rect(layout: layout)) { - selection.insert(idx) - } - } - return selection - } -} diff --git a/Sources/Flow/Model/Patch+Layout.swift b/Sources/Flow/Model/Patch+Layout.swift index e640977..3f1fadf 100644 --- a/Sources/Flow/Model/Patch+Layout.swift +++ b/Sources/Flow/Model/Patch+Layout.swift @@ -8,30 +8,36 @@ 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 + let node = nodes[withId: nodeId] + node.position = point // 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 +48,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 +60,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 +69,14 @@ 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 { + let node = nodes[withId: nodeId] + node.position = .init( x: xPos, y: origin.y + yOffset ) - let nodeHeight = nodes[nodeIndex].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 a10be9b..3e08518 100644 --- a/Sources/Flow/Model/Patch.swift +++ b/Sources/Flow/Model/Patch.swift @@ -2,18 +2,121 @@ import CoreGraphics import Foundation +import SwiftUI +import Combine +import os.log /// 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 { + + /// 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 } - public init(nodes: [Node], wires: Set) { + /// Called when a wire is removed. + var wireRemoved: Patch.WireRemovedHandler = { _ in } + + @Published public var nodes: [BaseNode] + + @Published private(set) var wires: Set + + @Published var nodeToShow: BaseNode? + + public init(nodes: [BaseNode], wires: Set) { self.nodes = nodes - self.wires = wires + self.wires = Set() + + wires.forEach { wire in + connect(wire) + } + + observeNotes() + } + + func reset() { + self.wires = Set() + self.nodes = [] + self.nodesCancellables.forEach { cancellable in + cancellable.cancel() + } + } + + 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] + try? 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] + + try? 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 528681f..5205876 100644 --- a/Sources/Flow/Model/Port.swift +++ b/Sources/Flow/Model/Port.swift @@ -1,37 +1,58 @@ // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ import Foundation +import Combine +import SwiftUI /// 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: any Node, _ portKeyPath: KeyPath<[any PortProtocol], any PortProtocol>) { + 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: any Node, _ portKeyPath: KeyPath<[any PortProtocol], any PortProtocol>) { + 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 } } @@ -41,23 +62,160 @@ 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, 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 valueUpdate: ValueUpdate? { get set } + var valueType: T.Type { get } + + 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) throws + 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 struct Port: Equatable, Hashable { +public class Port: Identifiable, ObservableObject, PortProtocol { + + 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 frame: CGRect? +// @Published public var value: T? + @Published public var valueUpdate: ValueUpdate? + 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 + case wrongPortType + } + +// var valueContainer: PortValue + + public init(name: String, type: PortType, valueType: T.Type, parentNodeId: NodeId) { self.name = name self.type = type + self.valueType = valueType + + 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 + } + + return false + } + + 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.$valueUpdate.removeDuplicates().sink { [weak self] newValue in + self?.valueUpdate = newValue + } + + connectedToOutputs.insert(OutputID(port.nodeId, port.id)) + port.connectedToInputs.insert(InputID(nodeId, id)) + } + + 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 + } + + 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 { + guard let port = first(where: { $0.id == id }) else { + fatalError("Port with identifier \(id.uuidString) not found") + } + + return port + } } } 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..b5f60cf --- /dev/null +++ b/Sources/Flow/Nodes/IntNode.swift @@ -0,0 +1,74 @@ +// +// IntNode.swift +// +// +// Created by Alessio Nossa on 20/04/2023. +// + +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +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) + ] + + #if canImport(UIKit) + titleBarColor = Color(UIColor.systemMint) + #elseif canImport(AppKit) + titleBarColor = Color(NSColor.systemMint) + #endif + + + 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/StringNode.swift b/Sources/Flow/Nodes/StringNode.swift new file mode 100644 index 0000000..72557c3 --- /dev/null +++ b/Sources/Flow/Nodes/StringNode.swift @@ -0,0 +1,86 @@ +// +// StringNode.swift +// +// +// Created by Alessio Nossa on 20/04/2023. +// + +import SwiftUI +import Combine +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +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 { + 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) + } + + } + } + + @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) + ] + + #if canImport(UIKit) + titleBarColor = Color(UIColor.systemTeal) + #elseif canImport(AppKit) + titleBarColor = Color(NSColor.systemTeal) + #endif + + 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) + } + } + + public func setValue(_ newString: String) { + self.valueUpdate = .init(newString) + } +} diff --git a/Sources/Flow/Nodes/TriggerButtonNode.swift b/Sources/Flow/Nodes/TriggerButtonNode.swift new file mode 100644 index 0000000..a03d91d --- /dev/null +++ b/Sources/Flow/Nodes/TriggerButtonNode.swift @@ -0,0 +1,51 @@ +// +// TriggerButtonNode.swift +// +// +// Created by Alessio Nossa on 20/04/2023. +// + +import SwiftUI +import Combine +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +public class TriggerButtonNode: BaseNode { + + struct TriggerMiddleView: View { + @ObservedObject var node: TriggerButtonNode + + var body: some View { + Button("Action!", action: { + node.trigger.send(ValueUpdate(())) + }) + .buttonStyle(.borderedProminent) + .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) + ] + + #if canImport(UIKit) + titleBarColor = Color(UIColor.systemOrange) + #elseif canImport(AppKit) + titleBarColor = Color(NSColor.systemOrange) + #endif + + middleView = AnyView(TriggerMiddleView(node: self)) + + 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 new file mode 100644 index 0000000..33caf31 --- /dev/null +++ b/Sources/Flow/Views/ConnectorView.swift @@ -0,0 +1,214 @@ +// +// ConnectorView.swift +// +// +// Created by Alessio Nossa on 18/04/2023. +// + +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 + } + + 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: + 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 { + VStack(spacing: 4) { + + ZStack(alignment: .center) { + GeometryReader { proxy in + Circle() + .fill(Color.mint) + .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 isConnected { + Circle() + .fill(Color.black) + .frame(width: 8, height: 8) + } + + } + .overlay { + if isCurrentPositinInput { + Circle() + .fill(isPossibleInput ? .green : .red) + } + } + .frame(width: 20, height: 20) + .scaleEffect((isDragging || isPossibleInput) ? 1.2 : 1.0) + .gesture(dragGesture) + + Text(connector.name) + .font(.caption) + } + .animation(.easeInOut, value: isDragging) + .animation(.easeInOut, value: isPossibleInput) + .animation(.easeInOut, value: isConnected) + .animation(.easeInOut, value: isCurrentPositinInput) + } + + 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 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, + 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 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, currentPositionInputId: currentPositionInputId) + } + }) + .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 inputPort(in 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 + } + + 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 + } + + func findPossibleInputPortId(outputFrame: CGRect, offset: CGSize) -> InputID? { + guard let possibleInputPort = findPossibleInputPort(outputFrame: outputFrame, offset: offset) else { + return nil + } + return InputID(possibleInputPort.nodeId, possibleInputPort.id) + } +} + +struct ConnectorView_Previews: PreviewProvider { + static var previews: some View { + EmptyView() + // ConnectorView(connector: ) + } +} diff --git a/Sources/Flow/Views/NodeEditor+Drawing.swift b/Sources/Flow/Views/NodeEditor+Drawing.swift index 5642043..25bdca4 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, @@ -31,206 +24,64 @@ extension GraphicsContext { style: StrokeStyle(lineWidth: 2.0, lineCap: .round) ) } -} - -extension NodeEditor { - @inlinable @inline(__always) - func color(for type: PortType, isOutput: Bool) -> Color { - style.color(for: type, isOutput: isOutput) ?? .gray - } - - func drawInputPort( - cx: GraphicsContext, - node: 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: 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)) - } + func strokeDashedLine(from startPoint: CGPoint, to endPoint: CGPoint, pattern: [CGFloat]) { + var path = Path() + path.move(to: startPoint) + path.addLine(to: endPoint) - 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 + let strokeStyle = StrokeStyle(lineWidth: 1, dash: pattern) + + stroke(path, with: .color(Color.gray.opacity(0.2)), style: strokeStyle) } +} - func drawNodes(cx: GraphicsContext, viewport: CGRect) { - - 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 (nodeIndex, node) in patch.nodes.enumerated() { - let offset = self.offset(for: nodeIndex) - 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(nodeIndex) - } - - 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(nodeIndex, 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(nodeIndex, i)) - ) - } - } - } +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 fromPoint = self.patch.nodes[wire.output.nodeIndex].outputRect( - output: wire.output.portIndex, - layout: self.layout - ) - .offset(by: self.offset(for: wire.output.nodeIndex)).center + 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 drawDashedBackgroundLines(_ cx: GraphicsContext, _ size: CGSize) { + let width = size.width + let height = size.height - let toPoint = self.patch.nodes[wire.input.nodeIndex].inputRect( - input: wire.input.portIndex, - layout: self.layout - ) - .offset(by: self.offset(for: wire.input.nodeIndex)).center + // 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) - 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) - } + 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 { - let outputRect = self.patch - .nodes[output.nodeIndex] - .outputRect(output: output.portIndex, 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) } } @@ -242,11 +93,10 @@ extension NodeEditor { } func gradient(for outputID: OutputID) -> Gradient { - let portType = patch - .nodes[outputID.nodeIndex] - .outputs[outputID.portIndex] - .type - return style.gradient(for: portType) ?? .init(colors: [.gray]) + let port = patch + .nodes[portId: outputID] + + 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 c6f65ea..0160b3c 100644 --- a/Sources/Flow/Views/NodeEditor+Gestures.swift +++ b/Sources/Flow/Views/NodeEditor+Gestures.swift @@ -4,161 +4,40 @@ import SwiftUI 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) + enum DragInfo: Equatable { + 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 } - /// 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) - wireAdded(wire) - } - - 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 - 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 - - 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(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] - // 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 - 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(nodeIndex): - patch.moveNode( - nodeIndex: nodeIndex, - offset: translation, - nodeMoved: self.nodeMoved - ) - if selection.contains(nodeIndex) { - for idx in selection where idx != nodeIndex { - patch.moveNode( - nodeIndex: idx, - offset: translation, - nodeMoved: self.nodeMoved - ) - } - } - case let .output(nodeIndex, portIndex): - let type = patch.nodes[nodeIndex].outputs[portIndex].type - if let input = findInput(point: location, type: type) { - connect(OutputID(nodeIndex, portIndex), to: input) - } - case let .input(nodeIndex, portIndex): - let type = patch.nodes[nodeIndex].inputs[portIndex].type - // Is a wire attached to the input? - if let attachedWire = attachedWire(inputID: InputID(nodeIndex, portIndex)) { - patch.wires.remove(attachedWire) - wireRemoved(attachedWire) - if let input = findInput(point: location, type: type) { - 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 - } - } - } - } +//#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) +// } +//} diff --git a/Sources/Flow/Views/NodeEditor+Modifiers.swift b/Sources/Flow/Views/NodeEditor+Modifiers.swift index 8969286..0385db2 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. @@ -37,56 +35,56 @@ 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 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 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 +// +// 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+Rects.swift b/Sources/Flow/Views/NodeEditor+Rects.swift deleted file mode 100644 index 755352d..0000000 --- a/Sources/Flow/Views/NodeEditor+Rects.swift +++ /dev/null @@ -1,70 +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 idx: NodeIndex) -> CGSize { - if patch.nodes[idx].locked { - return .zero - } - switch dragInfo { - case let .node(index: index, offset: offset): - if idx == index { - return offset - } - if selection.contains(index), selection.contains(idx) { - // Offset other selected node only if we're dragging the - // selection. - return offset - } - default: - return .zero - } - return .zero - } - - /// Search for inputs. - func findInput(node: Node, point: CGPoint, type: PortType) -> PortIndex? { - node.inputs.enumerated().first { portIndex, input in - input.type == type && node.inputRect(input: portIndex, layout: layout).contains(point) - }?.0 - } - - /// 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) - } - } - return nil - } - - /// Search for outputs. - func findOutput(node: Node, point: CGPoint) -> PortIndex? { - node.outputs.enumerated().first { portIndex, _ in - node.outputRect(output: portIndex, layout: layout).contains(point) - }?.0 - } - - /// 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) - } - } - 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+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 cbfbc24..fcc5cb7 100644 --- a/Sources/Flow/Views/NodeEditor.swift +++ b/Sources/Flow/Views/NodeEditor.swift @@ -7,36 +7,26 @@ 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. - @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 - - /// Cache resolved text - @StateObject var textCache = TextCache() + + @State var backgroundColor: Color /// Node moved handler closure. - public typealias NodeMovedHandler = (_ index: NodeIndex, + public typealias NodeMovedHandler = (_ index: NodeId, _ location: CGPoint) -> Void /// 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 @@ -51,11 +41,13 @@ 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, + backgroundColor: Color = .clear, + selection: Binding>, layout: LayoutConstants = LayoutConstants()) { - _patch = patch + self.patch = patch + self._backgroundColor = .init(initialValue: backgroundColor) _selection = selection self.layout = layout } @@ -71,26 +63,47 @@ public struct NodeEditor: View { @State var mousePosition: CGPoint = CGPoint(x: CGFloat.infinity, y: CGFloat.infinity) 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) + GeometryReader { geometryProxy in + ScrollViewReader { scrollProxy in + ScrollView([.horizontal, .vertical], showsIndicators: true) { + ZStack { + + 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) + } + + 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) + + } } - WorkspaceView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) - #if os(macOS) - .gesture(commandGesture) - #endif - .gesture(dragGesture) } +// WorkspaceView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) +// #if os(macOS) +// .gesture(commandGesture) +// #endif +// .gesture(dragGesture) + .environmentObject(patch) .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..68f6282 --- /dev/null +++ b/Sources/Flow/Views/NodeView.swift @@ -0,0 +1,149 @@ +// +// NodeView.swift +// +// +// Created by Alessio Nossa on 18/04/2023. +// + +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif + +struct NodeView: View { + @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 { + GeometryReader { geometryProxy in + 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) + .gesture(dragGesture) + + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .center) { + ForEach(node.inputs, id: \.id) { input in + ConnectorView(connector: input, gestureState: gestureState) + } + } + .padding(.leading, 8) + + if let middleView = node.middleView { + AnyView(middleView) + } + + VStack(alignment: .center) { + ForEach(node.outputs, id: \.id) { output in + ConnectorView(connector: output, gestureState: gestureState) + } + } + .padding(.trailing, 8) + } + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } +#if canImport(UIKit) + .background( + Color(UIColor.systemBackground) + .opacity(0.6) + ) +#endif +#if os(macOS) + .background( + Color(NSColor.textBackgroundColor) + .opacity(0.6) + ) +#endif + .cornerRadius(8) + .frame(maxWidth: 500) + .shadow(radius: 5) + .scaleEffect(dragging ? 1.1 : 1.0) + .fixedSize(horizontal: true, vertical: false) + .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 + } + } + } + } + + + 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)) + + return node + } + + static var previews: some View { + GeometryReader { proxy in + NodeView(node: getTestNode(proxy: proxy), gestureState: .init(initialValue: .none)) + + } + .background(.clear) + .previewLayout(.sizeThatFits) +// .previewLayout(.fixed(width: 250, height: 150)) + + } +} 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 - } -}