├── .spi.yml ├── Demo ├── Shared │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── FlowDemoApp.swift │ └── ContentView.swift ├── FlowDemo.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── project.pbxproj └── macOS │ └── macOS.entitlements ├── Sources └── Flow │ ├── Flow.docc │ ├── Resources │ │ └── screenshot.png │ └── Flow.md │ ├── Model │ ├── LayoutConstants.swift │ ├── Patch.swift │ ├── Wire.swift │ ├── Node+Gestures.swift │ ├── Patch+Gestures.swift │ ├── Node+Layout.swift │ ├── Node.swift │ ├── Port.swift │ └── Patch+Layout.swift │ ├── Views │ ├── TextCache.swift │ ├── NodeEditor+Rects.swift │ ├── NodeEditor+Style.swift │ ├── NodeEditor+Modifiers.swift │ ├── NodeEditor.swift │ ├── NodeEditor+Gestures.swift │ ├── WorkspaceView.swift │ └── NodeEditor+Drawing.swift │ └── Helpers.swift ├── Flow.playground ├── contents.xcplayground └── Contents.swift ├── Package.swift ├── Tests └── FlowTests │ ├── NodeTests.swift │ └── LayoutTests.swift ├── .github └── workflows │ └── tests.yml ├── LICENSE ├── README.md └── .gitignore /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Flow] -------------------------------------------------------------------------------- /Demo/Shared/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Flow/Flow.docc/Resources/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AudioKit/Flow/HEAD/Sources/Flow/Flow.docc/Resources/screenshot.png -------------------------------------------------------------------------------- /Demo/Shared/FlowDemoApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct FlowDemoApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Demo/FlowDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/Shared/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Flow.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Demo/FlowDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Flow", 7 | platforms: [.macOS(.v12), .iOS(.v15)], 8 | products: [.library(name: "Flow", targets: ["Flow"])], 9 | targets: [ 10 | .target(name: "Flow"), 11 | .testTarget(name: "FlowTests", dependencies: ["Flow"]), 12 | ] 13 | ) 14 | -------------------------------------------------------------------------------- /Demo/macOS/macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Tests/FlowTests/NodeTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | @testable import Flow 4 | import XCTest 5 | 6 | final class NodeTests: XCTestCase { 7 | /// This test ensures disambiguation for identical `Node` init signature overloads 8 | /// where inputs and outputs are empty. 9 | /// It will throw a compiler error if it cannot determine which to use. 10 | /// No logic testing is necessary here. 11 | func testNodeInitDisambiguation() throws { 12 | _ = Node(name: "Name") 13 | _ = Node(name: "Name", position: .zero, inputs: [], outputs: []) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Flow/Model/LayoutConstants.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import CoreGraphics 4 | import SwiftUI 5 | 6 | /// Define the layout geometry of the nodes. 7 | public struct LayoutConstants { 8 | public var portSize = CGSize(width: 20, height: 20) 9 | public var portSpacing: CGFloat = 10 10 | public var nodeWidth: CGFloat = 200 11 | public var nodeTitleHeight: CGFloat = 40 12 | public var nodeSpacing: CGFloat = 40 13 | public var nodeTitleFont = Font.title 14 | public var portNameFont = Font.caption 15 | public var nodeCornerRadius: CGFloat = 5 16 | 17 | public init() {} 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Patch.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import CoreGraphics 4 | import Foundation 5 | 6 | /// Data model for Flow. 7 | /// 8 | /// Write a function to generate a `Patch` from your own data model 9 | /// as well as a function to update your data model when the `Patch` changes. 10 | /// Use SwiftUI's `onChange(of:)` to monitor changes, or use `NodeEditor.onNodeAdded`, etc. 11 | public struct Patch: Equatable { 12 | public var nodes: [Node] 13 | public var wires: Set 14 | 15 | public init(nodes: [Node], wires: Set) { 16 | self.nodes = nodes 17 | self.wires = wires 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Wire.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import Foundation 4 | 5 | /// An (output, input) pair. Represents a connection between nodes. 6 | /// 7 | /// Node graphs are often represented with the connections 8 | /// on the inputs instead of a separate set, which doesn't allow multiple inputs connected to 9 | /// a single node. Our data model allows arbitrary connections, though we don't yet support 10 | /// editing of arbitrary connection graphs (an input can only have one wire). 11 | public struct Wire: Equatable, Hashable { 12 | public let output: OutputID 13 | public let input: InputID 14 | 15 | /// Initialize the wire with an input and output 16 | /// - Parameters: 17 | /// - from: output from a node 18 | /// - to: input into a node 19 | public init(from: OutputID, to: InputID) { 20 | output = from 21 | input = to 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Flow/Views/TextCache.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import Foundation 4 | import SwiftUI 5 | 6 | /// Caches "resolved" text. 7 | /// 8 | /// XXX: we will need to know when to clear the cache. 9 | class TextCache: ObservableObject { 10 | 11 | struct Key: Equatable, Hashable { 12 | var string: String 13 | var font: Font 14 | } 15 | 16 | var cache: [Key: GraphicsContext.ResolvedText] = [:] 17 | 18 | func text(string: String, 19 | font: Font, 20 | _ cx: GraphicsContext) -> GraphicsContext.ResolvedText { 21 | 22 | let key = Key(string: string, font: font) 23 | 24 | if let resolved = cache[key] { 25 | return resolved 26 | } 27 | 28 | let resolved = cx.resolve(Text(string).font(font)) 29 | cache[key] = resolved 30 | return resolved 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | swift_test: 12 | name: Test 13 | runs-on: macos-latest 14 | steps: 15 | - name: Check out Flow 16 | uses: actions/checkout@v4 17 | - name: Test Flow 18 | run: swift test -c release 19 | 20 | # Build the demo projects. 21 | build_demo: 22 | name: Build Demo 23 | runs-on: macos-latest 24 | needs: [swift_test] 25 | steps: 26 | - name: Check out Flow 27 | uses: actions/checkout@v4 28 | - name: Build Demo 29 | run: xcodebuild build -project Demo/FlowDemo.xcodeproj -scheme FlowDemo -destination "name=My Mac" 30 | 31 | # Send notification to Discord on failure. 32 | send_notification: 33 | name: Send Notification 34 | uses: AudioKit/ci/.github/workflows/send_notification.yml@main 35 | needs: [swift_test, build_demo] 36 | if: ${{ failure() && github.ref == 'refs/heads/main' }} 37 | secrets: inherit 38 | -------------------------------------------------------------------------------- /Tests/FlowTests/LayoutTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import Flow 4 | import XCTest 5 | 6 | final class LayoutTests: XCTestCase { 7 | func testNodeRects() throws { 8 | let processor = Node(name: "processor", 9 | position: CGPoint(x: 400, y: 100), 10 | inputs: [Port(name: "in", type: .signal)], 11 | outputs: [Port(name: "out", type: .signal)]) 12 | 13 | XCTAssertEqual(processor.rect(layout: LayoutConstants()), 14 | CGRect(origin: processor.position, size: CGSize(width: 200, height: 80))) 15 | 16 | XCTAssertEqual(processor.inputRect(input: 0, layout: LayoutConstants()), 17 | CGRect(origin: CGPoint(x: 410, y: 150), size: CGSize(width: 20, height: 20))) 18 | 19 | XCTAssertEqual(processor.outputRect(output: 0, layout: LayoutConstants()), 20 | CGRect(origin: CGPoint(x: 570, y: 150), size: CGSize(width: 20, height: 20))) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 AudioKit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Node+Gestures.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import CoreGraphics 4 | import Foundation 5 | 6 | extension Node { 7 | public func translate(by offset: CGSize) -> Node { 8 | var result = self 9 | result.position.x += offset.width 10 | result.position.y += offset.height 11 | return result 12 | } 13 | 14 | func hitTest(nodeIndex: Int, point: CGPoint, layout: LayoutConstants) -> Patch.HitTestResult? { 15 | for (inputIndex, _) in inputs.enumerated() { 16 | if inputRect(input: inputIndex, layout: layout).contains(point) { 17 | return .input(nodeIndex, inputIndex) 18 | } 19 | } 20 | for (outputIndex, _) in outputs.enumerated() { 21 | if outputRect(output: outputIndex, layout: layout).contains(point) { 22 | return .output(nodeIndex, outputIndex) 23 | } 24 | } 25 | 26 | if rect(layout: layout).contains(point) { 27 | return .node(nodeIndex) 28 | } 29 | 30 | return nil 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Patch+Gestures.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import CoreGraphics 4 | import Foundation 5 | 6 | extension Patch { 7 | enum HitTestResult { 8 | case node(NodeIndex) 9 | case input(NodeIndex, PortIndex) 10 | case output(NodeIndex, PortIndex) 11 | } 12 | 13 | /// Hit test a point against the whole patch. 14 | func hitTest(point: CGPoint, layout: LayoutConstants) -> HitTestResult? { 15 | for (nodeIndex, node) in nodes.enumerated().reversed() { 16 | if let result = node.hitTest(nodeIndex: nodeIndex, point: point, layout: layout) { 17 | return result 18 | } 19 | } 20 | 21 | return nil 22 | } 23 | 24 | mutating func moveNode( 25 | nodeIndex: NodeIndex, 26 | offset: CGSize, 27 | nodeMoved: NodeEditor.NodeMovedHandler 28 | ) { 29 | if !nodes[nodeIndex].locked { 30 | nodes[nodeIndex].position += offset 31 | nodeMoved(nodeIndex, nodes[nodeIndex].position) 32 | } 33 | } 34 | 35 | func selected(in rect: CGRect, layout: LayoutConstants) -> Set { 36 | var selection = Set() 37 | 38 | for (idx, node) in nodes.enumerated() { 39 | if rect.intersects(node.rect(layout: layout)) { 40 | selection.insert(idx) 41 | } 42 | } 43 | return selection 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Node+Layout.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import CoreGraphics 4 | import Foundation 5 | 6 | public extension Node { 7 | /// Calculates the bounding rectangle for a node. 8 | func rect(layout: LayoutConstants) -> CGRect { 9 | let maxio = CGFloat(max(inputs.count, outputs.count)) 10 | let size = CGSize(width: layout.nodeWidth, 11 | height: CGFloat((maxio * (layout.portSize.height + layout.portSpacing)) + layout.nodeTitleHeight + layout.portSpacing)) 12 | 13 | return CGRect(origin: position, size: size) 14 | } 15 | 16 | /// Calculates the bounding rectangle for an input port (not including the name). 17 | func inputRect(input: PortIndex, layout: LayoutConstants) -> CGRect { 18 | let y = layout.nodeTitleHeight + CGFloat(input) * (layout.portSize.height + layout.portSpacing) + layout.portSpacing 19 | return CGRect(origin: position + CGSize(width: layout.portSpacing, height: y), 20 | size: layout.portSize) 21 | } 22 | 23 | /// Calculates the bounding rectangle for an output port (not including the name). 24 | func outputRect(output: PortIndex, layout: LayoutConstants) -> CGRect { 25 | let y = layout.nodeTitleHeight + CGFloat(output) * (layout.portSize.height + layout.portSpacing) + layout.portSpacing 26 | return CGRect(origin: position + CGSize(width: layout.nodeWidth - layout.portSpacing - layout.portSize.width, height: y), 27 | size: layout.portSize) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Flow/Flow.docc/Flow.md: -------------------------------------------------------------------------------- 1 | # ``Flow`` 2 | 3 | Generic node graph editor. 4 | 5 | ## Overview 6 | 7 | Code is hosted on Github: [](https://github.com/AudioKit/Flow/) 8 | 9 | ![Screenshot](screenshot) 10 | 11 | Generate a `Patch` from your own data model. Update your data model when the `Patch` changes. 12 | 13 | ```swift 14 | func simplePatch() -> Patch { 15 | let generator = Node(name: "generator", outputs: ["out"]) 16 | let processor = Node(name: "processor", inputs: ["in"], outputs: ["out"]) 17 | let mixer = Node(name: "mixer", inputs: ["in1", "in2"], outputs: ["out"]) 18 | let output = Node(name: "output", inputs: ["in"]) 19 | 20 | let nodes = [generator, processor, generator, processor, mixer, output] 21 | 22 | let wires = Set([Wire(from: OutputID(0, 0), to: InputID(1, 0)), 23 | Wire(from: OutputID(1, 0), to: InputID(4, 0)), 24 | Wire(from: OutputID(2, 0), to: InputID(3, 0)), 25 | Wire(from: OutputID(3, 0), to: InputID(4, 1)), 26 | Wire(from: OutputID(4, 0), to: InputID(5, 0))]) 27 | 28 | var patch = Patch(nodes: nodes, wires: wires) 29 | patch.recursiveLayout(nodeIndex: 5, at: CGPoint(x: 800, y: 50)) 30 | return patch 31 | } 32 | 33 | struct ContentView: View { 34 | @State var patch = simplePatch() 35 | @State var selection = Set() 36 | 37 | var body: some View { 38 | NodeEditor(patch: $patch, selection: $selection) 39 | .onNodeMoved { index, location in 40 | print("Node at index \(index) moved to \(location)") 41 | } 42 | .onWireAdded { wire in 43 | print("Added wire: \(wire)") 44 | } 45 | .onWireRemoved { wire in 46 | print("Removed wire: \(wire)") 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | 53 | ## Topics 54 | 55 | ### Group 56 | 57 | - ``Symbol`` 58 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Node.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import CoreGraphics 4 | import SwiftUI 5 | 6 | /// Nodes are identified by index in `Patch/nodes``. 7 | public typealias NodeIndex = Int 8 | 9 | /// Nodes are identified by index in ``Patch/nodes``. 10 | /// 11 | /// Using indices as IDs has proven to be easy and fast for our use cases. The ``Patch`` should be 12 | /// generated from your own data model, not used as your data model, so there isn't a requirement that 13 | /// the indices be consistent across your editing operations (such as deleting nodes). 14 | public struct Node: Equatable { 15 | public var name: String 16 | public var position: CGPoint 17 | public var titleBarColor: Color 18 | 19 | /// Is the node position fixed so it can't be edited in the UI? 20 | public var locked = false 21 | 22 | public var inputs: [Port] 23 | public var outputs: [Port] 24 | 25 | @_disfavoredOverload 26 | public init(name: String, 27 | position: CGPoint = .zero, 28 | titleBarColor: Color = Color.clear, 29 | locked: Bool = false, 30 | inputs: [Port] = [], 31 | outputs: [Port] = []) 32 | { 33 | self.name = name 34 | self.position = position 35 | self.titleBarColor = titleBarColor 36 | self.locked = locked 37 | self.inputs = inputs 38 | self.outputs = outputs 39 | } 40 | 41 | public init(name: String, 42 | position: CGPoint = .zero, 43 | titleBarColor: Color = Color.clear, 44 | locked: Bool = false, 45 | inputs: [String] = [], 46 | outputs: [String] = []) 47 | { 48 | self.name = name 49 | self.position = position 50 | self.titleBarColor = titleBarColor 51 | self.locked = locked 52 | self.inputs = inputs.map { Port(name: $0) } 53 | self.outputs = outputs.map { Port(name: $0) } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flow 2 | 3 | [![Tests](https://github.com/AudioKit/Flow/actions/workflows/tests.yml/badge.svg)](https://github.com/AudioKit/Flow/actions/workflows/tests.yml) 4 | 5 | Generic node graph editor. Generate a `Patch` from your own data model. Update 6 | your data model when the `Patch` changes. 7 | 8 | ![flow-demo](https://user-images.githubusercontent.com/13122/204678924-64a37ef5-522e-4da5-9c79-c2c6f9f745a6.gif) 9 | 10 | ```swift 11 | func simplePatch() -> Patch { 12 | let generator = Node(name: "generator", outputs: ["out"]) 13 | let processor = Node(name: "processor", inputs: ["in"], outputs: ["out"]) 14 | let mixer = Node(name: "mixer", inputs: ["in1", "in2"], outputs: ["out"]) 15 | let output = Node(name: "output", inputs: ["in"]) 16 | 17 | let nodes = [generator, processor, generator, processor, mixer, output] 18 | 19 | let wires = Set([Wire(from: OutputID(0, 0), to: InputID(1, 0)), 20 | Wire(from: OutputID(1, 0), to: InputID(4, 0)), 21 | Wire(from: OutputID(2, 0), to: InputID(3, 0)), 22 | Wire(from: OutputID(3, 0), to: InputID(4, 1)), 23 | Wire(from: OutputID(4, 0), to: InputID(5, 0))]) 24 | 25 | var patch = Patch(nodes: nodes, wires: wires) 26 | patch.recursiveLayout(nodeIndex: 5, at: CGPoint(x: 800, y: 50)) 27 | return patch 28 | } 29 | 30 | struct ContentView: View { 31 | @State var patch = simplePatch() 32 | @State var selection = Set() 33 | 34 | var body: some View { 35 | NodeEditor(patch: $patch, selection: $selection) 36 | .onNodeMoved { index, location in 37 | print("Node at index \(index) moved to \(location)") 38 | } 39 | .onWireAdded { wire in 40 | print("Added wire: \(wire)") 41 | } 42 | .onWireRemoved { wire in 43 | print("Removed wire: \(wire)") 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ## Documentation 50 | 51 | The API Reference can be found on [the AudioKit Website](https://www.audiokit.io/Flow). 52 | Package contains a demo project and a playground to help you get started quickly. 53 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Port.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import Foundation 4 | 5 | /// Ports are identified by index within a node. 6 | public typealias PortIndex = Int 7 | 8 | /// Uniquely identifies an input by indices. 9 | public struct InputID: Equatable, Hashable { 10 | public let nodeIndex: NodeIndex 11 | public let portIndex: PortIndex 12 | 13 | /// Initialize an input 14 | /// - Parameters: 15 | /// - nodeIndex: Index for the node the input belongs 16 | /// - portIndex: Index to the input within the node 17 | public init(_ nodeIndex: NodeIndex, _ portIndex: PortIndex) { 18 | self.nodeIndex = nodeIndex 19 | self.portIndex = portIndex 20 | } 21 | } 22 | 23 | /// Uniquely identifies an output by indices. 24 | public struct OutputID: Equatable, Hashable { 25 | public let nodeIndex: NodeIndex 26 | public let portIndex: PortIndex 27 | 28 | /// Initialize an output 29 | /// - Parameters: 30 | /// - nodeIndex: Index for the node the output belongs 31 | /// - portIndex: Index to the output within the node 32 | public init(_ nodeIndex: NodeIndex, _ portIndex: PortIndex) { 33 | self.nodeIndex = nodeIndex 34 | self.portIndex = portIndex 35 | } 36 | } 37 | 38 | /// Support for different types of connections. 39 | /// 40 | /// Some graphs have different types of ports which can't be 41 | /// connected to each other. Here we offer two common types 42 | /// as well as a custom option for your own types. XXX: not implemented yet 43 | public enum PortType: Equatable, Hashable { 44 | case control 45 | case signal 46 | case midi 47 | case custom(String) 48 | } 49 | 50 | /// Information for either an input or an output. 51 | public struct Port: Equatable, Hashable { 52 | public let name: String 53 | public let type: PortType 54 | 55 | /// Initialize the port with a name and type 56 | /// - Parameters: 57 | /// - name: Descriptive label of the port 58 | /// - type: Type of port 59 | public init(name: String, type: PortType = .signal) { 60 | self.name = name 61 | self.type = type 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Demo/Shared/ContentView.swift: -------------------------------------------------------------------------------- 1 | import Flow 2 | import SwiftUI 3 | 4 | func simplePatch() -> Patch { 5 | let generator = Node(name: "generator", titleBarColor: Color.cyan, outputs: ["out"]) 6 | let processor = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) 7 | let mixer = Node(name: "mixer", titleBarColor: Color.gray, inputs: ["in1", "in2"], outputs: ["out"]) 8 | let output = Node(name: "output", titleBarColor: Color.purple, inputs: ["in"]) 9 | 10 | let nodes = [generator, processor, generator, processor, mixer, output] 11 | 12 | let wires = Set([Wire(from: OutputID(0, 0), to: InputID(1, 0)), 13 | Wire(from: OutputID(1, 0), to: InputID(4, 0)), 14 | Wire(from: OutputID(2, 0), to: InputID(3, 0)), 15 | Wire(from: OutputID(3, 0), to: InputID(4, 1)), 16 | Wire(from: OutputID(4, 0), to: InputID(5, 0))]) 17 | 18 | var patch = Patch(nodes: nodes, wires: wires) 19 | patch.recursiveLayout(nodeIndex: 5, at: CGPoint(x: 800, y: 50)) 20 | return patch 21 | } 22 | 23 | /// Bit of a stress test to show how Flow performs with more nodes. 24 | func randomPatch() -> Patch { 25 | var randomNodes: [Node] = [] 26 | for n in 0 ..< 50 { 27 | let randomPoint = CGPoint(x: 1000 * Double.random(in: 0 ... 1), 28 | y: 1000 * Double.random(in: 0 ... 1)) 29 | randomNodes.append(Node(name: "node\(n)", 30 | position: randomPoint, 31 | inputs: ["In"], 32 | outputs: ["Out"])) 33 | } 34 | 35 | var randomWires: Set = [] 36 | for n in 0 ..< 50 { 37 | randomWires.insert(Wire(from: OutputID(n, 0), to: InputID(Int.random(in: 0 ... 49), 0))) 38 | } 39 | return Patch(nodes: randomNodes, wires: randomWires) 40 | } 41 | 42 | struct ContentView: View { 43 | @State var patch = simplePatch() 44 | @State var selection = Set() 45 | 46 | func addNode() { 47 | let newNode = Node(name: "processor", titleBarColor: Color.red, inputs: ["in"], outputs: ["out"]) 48 | patch.nodes.append(newNode) 49 | } 50 | 51 | var body: some View { 52 | ZStack(alignment: .topTrailing) { 53 | NodeEditor(patch: $patch, selection: $selection) 54 | Button("Add Node", action: addNode).padding() 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | .DS_Store 93 | 94 | Package.resolved -------------------------------------------------------------------------------- /Flow.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | import Flow 2 | import PlaygroundSupport 3 | import SwiftUI 4 | 5 | func simplePatch() -> Patch { 6 | let midiSource = Node(name: "MIDI source", 7 | outputs: [ 8 | Port(name: "out ch. 1", type: .midi), 9 | Port(name: "out ch. 2", type: .midi), 10 | ]) 11 | let generator = Node(name: "generator", 12 | inputs: [ 13 | Port(name: "midi in", type: .midi), 14 | Port(name: "CV in", type: .control), 15 | ], 16 | outputs: [Port(name: "out")]) 17 | let processor = Node(name: "processor", inputs: ["in"], outputs: ["out"]) 18 | let mixer = Node(name: "mixer", inputs: ["in1", "in2"], outputs: ["out"]) 19 | let output = Node(name: "output", inputs: ["in"]) 20 | 21 | let nodes = [midiSource, generator, processor, generator, processor, mixer, output] 22 | 23 | let wires = Set([ 24 | Wire(from: OutputID(0, 0), to: InputID(1, 0)), 25 | Wire(from: OutputID(0, 1), to: InputID(3, 0)), 26 | Wire(from: OutputID(1, 0), to: InputID(2, 0)), 27 | Wire(from: OutputID(2, 0), to: InputID(5, 0)), 28 | Wire(from: OutputID(3, 0), to: InputID(4, 0)), 29 | Wire(from: OutputID(4, 0), to: InputID(5, 1)), 30 | Wire(from: OutputID(5, 0), to: InputID(6, 0)), 31 | ]) 32 | 33 | var patch = Patch(nodes: nodes, wires: wires) 34 | patch.recursiveLayout(nodeIndex: 6, at: CGPoint(x: 1000, y: 50)) 35 | return patch 36 | } 37 | 38 | struct FlowDemoView: View { 39 | @State var patch = simplePatch() 40 | @State var selection = Set() 41 | 42 | public var body: some View { 43 | NodeEditor(patch: $patch, selection: $selection) 44 | .nodeColor(.secondary) 45 | .portColor(for: .control, .gray) 46 | .portColor(for: .signal, Gradient(colors: [.yellow, .blue])) 47 | .portColor(for: .midi, .red) 48 | 49 | .onNodeMoved { index, location in 50 | print("Node at index \(index) moved to \(location)") 51 | } 52 | .onWireAdded { wire in 53 | print("Added wire: \(wire)") 54 | } 55 | .onWireRemoved { wire in 56 | print("Removed wire: \(wire)") 57 | } 58 | } 59 | } 60 | 61 | PlaygroundPage.current.setLiveView(FlowDemoView().frame(width: 1200, height: 500)) 62 | PlaygroundPage.current.needsIndefiniteExecution = true 63 | -------------------------------------------------------------------------------- /Sources/Flow/Views/NodeEditor+Rects.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import SwiftUI 4 | 5 | public extension NodeEditor { 6 | /// Offset to apply to a node based on selection and gesture state. 7 | func offset(for idx: NodeIndex) -> CGSize { 8 | if patch.nodes[idx].locked { 9 | return .zero 10 | } 11 | switch dragInfo { 12 | case let .node(index: index, offset: offset): 13 | if idx == index { 14 | return offset 15 | } 16 | if selection.contains(index), selection.contains(idx) { 17 | // Offset other selected node only if we're dragging the 18 | // selection. 19 | return offset 20 | } 21 | default: 22 | return .zero 23 | } 24 | return .zero 25 | } 26 | 27 | /// Search for inputs. 28 | func findInput(node: Node, point: CGPoint, type: PortType) -> PortIndex? { 29 | node.inputs.enumerated().first { portIndex, input in 30 | input.type == type && node.inputRect(input: portIndex, layout: layout).contains(point) 31 | }?.0 32 | } 33 | 34 | /// Search for an input in the whole patch. 35 | func findInput(point: CGPoint, type: PortType) -> InputID? { 36 | // Search nodes in reverse to find nodes drawn on top first. 37 | for (nodeIndex, node) in patch.nodes.enumerated().reversed() { 38 | if let portIndex = findInput(node: node, point: point, type: type) { 39 | return InputID(nodeIndex, portIndex) 40 | } 41 | } 42 | return nil 43 | } 44 | 45 | /// Search for outputs. 46 | func findOutput(node: Node, point: CGPoint) -> PortIndex? { 47 | node.outputs.enumerated().first { portIndex, _ in 48 | node.outputRect(output: portIndex, layout: layout).contains(point) 49 | }?.0 50 | } 51 | 52 | /// Search for an output in the whole patch. 53 | func findOutput(point: CGPoint) -> OutputID? { 54 | // Search nodes in reverse to find nodes drawn on top first. 55 | for (nodeIndex, node) in patch.nodes.enumerated().reversed() { 56 | if let portIndex = findOutput(node: node, point: point) { 57 | return OutputID(nodeIndex, portIndex) 58 | } 59 | } 60 | return nil 61 | } 62 | 63 | /// Search for a node which intersects a point. 64 | func findNode(point: CGPoint) -> NodeIndex? { 65 | // Search nodes in reverse to find nodes drawn on top first. 66 | patch.nodes.enumerated().reversed().first { _, node in 67 | node.rect(layout: layout).contains(point) 68 | }?.0 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Flow/Helpers.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import Foundation 4 | import SwiftUI 5 | 6 | extension String { 7 | @_disfavoredOverload 8 | func removing(prefix: String) -> String? { 9 | guard hasPrefix(prefix) else { return nil } 10 | return String(dropFirst(prefix.count)) 11 | } 12 | } 13 | 14 | extension CGSize { 15 | @_disfavoredOverload 16 | var point: CGPoint { 17 | CGPoint(x: width, y: height) 18 | } 19 | } 20 | 21 | extension CGPoint { 22 | @_disfavoredOverload 23 | static func + (lhs: Self, rhs: CGSize) -> Self { 24 | Self(x: lhs.x + rhs.width, y: lhs.y + rhs.height) 25 | } 26 | 27 | @_disfavoredOverload 28 | static func - (lhs: Self, rhs: Self) -> CGSize { 29 | CGSize(width: lhs.x - rhs.x, height: lhs.y - rhs.y) 30 | } 31 | } 32 | 33 | extension CGSize { 34 | @_disfavoredOverload 35 | static func + (lhs: Self, rhs: Self) -> Self { 36 | Self(width: lhs.width + rhs.width, height: lhs.height + rhs.height) 37 | } 38 | 39 | @_disfavoredOverload 40 | static func - (pt: CGPoint, sz: Self) -> CGPoint { 41 | CGPoint(x: pt.x - sz.width, y: pt.y - sz.height) 42 | } 43 | 44 | @_disfavoredOverload 45 | static func - (lhs: Self, rhs: Self) -> Self { 46 | Self(width: lhs.width - rhs.width, height: lhs.height - rhs.height) 47 | } 48 | 49 | @_disfavoredOverload 50 | static func * (s: Double, sz: Self) -> Self { 51 | Self(width: s * sz.width, height: s * sz.height) 52 | } 53 | 54 | @_disfavoredOverload 55 | static func * (sz: Self, s: Double) -> Self { 56 | Self(width: s * sz.width, height: s * sz.height) 57 | } 58 | } 59 | 60 | extension CGRect { 61 | @_disfavoredOverload 62 | var center: CGPoint { 63 | origin + CGSize(width: size.width / 2, height: size.height / 2) 64 | } 65 | 66 | @_disfavoredOverload 67 | func offset(by off: CGSize) -> CGRect { 68 | offsetBy(dx: off.width, dy: off.height) 69 | } 70 | 71 | @_disfavoredOverload 72 | init(a: CGPoint, b: CGPoint) { 73 | self.init() 74 | origin = CGPoint(x: min(a.x, b.x), y: min(a.y, b.y)) 75 | size = CGSize(width: abs(a.x - b.x), height: abs(a.y - b.y)) 76 | } 77 | } 78 | 79 | extension CGPoint { 80 | var size: CGSize { 81 | CGSize(width: x, height: y) 82 | } 83 | 84 | var simd: SIMD2 { 85 | .init(x: Float(x), y: Float(y)) 86 | } 87 | 88 | @inlinable @inline(__always) 89 | func distance(to p: CGPoint) -> CGFloat { 90 | hypot(x - p.x, y - p.y) 91 | } 92 | 93 | @_disfavoredOverload 94 | static func += (lhs: inout CGPoint, rhs: CGSize) { 95 | lhs = lhs + rhs 96 | } 97 | } 98 | 99 | extension Color { 100 | static let magenta = Color(.sRGB, red: 1, green: 0, blue: 1, opacity: 1) 101 | } 102 | -------------------------------------------------------------------------------- /Sources/Flow/Views/NodeEditor+Style.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import SwiftUI 4 | 5 | public extension NodeEditor { 6 | /// Configuration used to determine rendering style of a ``NodeEditor`` instance. 7 | struct Style { 8 | /// Color used for rendering nodes. 9 | public var nodeColor: Color = .init(white: 0.3) 10 | 11 | /// Color used for rendering control wires. 12 | public var controlWire: WireStyle = .init() 13 | 14 | /// Color used for rendering signal wires. 15 | public var signalWire: WireStyle = .init() 16 | 17 | /// Color used for rendering MIDI wires. 18 | public var midiWire: WireStyle = .init() 19 | 20 | /// Colors used for rendering custom wires. 21 | /// Dictionary is keyed by the custom wire name. 22 | public var customWires: [String: WireStyle] = [:] 23 | 24 | /// Returns input or output port color for the specified port type. 25 | public func color(for portType: PortType, isOutput: Bool) -> Color? { 26 | switch portType { 27 | case .control: 28 | return isOutput ? controlWire.outputColor : controlWire.inputColor 29 | case .signal: 30 | return isOutput ? signalWire.outputColor : signalWire.inputColor 31 | case .midi: 32 | return isOutput ? midiWire.outputColor : midiWire.inputColor 33 | case let .custom(id): 34 | return isOutput ? customWires[id]?.outputColor : customWires[id]?.inputColor 35 | } 36 | } 37 | 38 | /// Returns port gradient for the specified port type. 39 | public func gradient(for portType: PortType) -> Gradient? { 40 | switch portType { 41 | case .control: 42 | return controlWire.gradient 43 | case .signal: 44 | return signalWire.gradient 45 | case .midi: 46 | return midiWire.gradient 47 | case let .custom(id): 48 | return customWires[id]?.gradient 49 | } 50 | } 51 | } 52 | } 53 | 54 | public extension NodeEditor.Style { 55 | /// Configuration used to determine rendering style of a ``NodeEditor`` wire type. 56 | struct WireStyle { 57 | public var inputColor: Color = .cyan 58 | public var outputColor: Color = .magenta 59 | 60 | /// Get or set the input and output colors as a `Gradient`. 61 | /// Only the first and last stops will be used. 62 | public var gradient: Gradient { 63 | get { 64 | Gradient(colors: [outputColor, inputColor]) 65 | } 66 | set { 67 | if let inputColor = newValue.stops.last?.color { 68 | self.inputColor = inputColor 69 | } 70 | if let outputColor = newValue.stops.first?.color { 71 | self.outputColor = outputColor 72 | } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Demo/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "2x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "83.5x83.5" 82 | }, 83 | { 84 | "idiom" : "ios-marketing", 85 | "scale" : "1x", 86 | "size" : "1024x1024" 87 | }, 88 | { 89 | "idiom" : "mac", 90 | "scale" : "1x", 91 | "size" : "16x16" 92 | }, 93 | { 94 | "idiom" : "mac", 95 | "scale" : "2x", 96 | "size" : "16x16" 97 | }, 98 | { 99 | "idiom" : "mac", 100 | "scale" : "1x", 101 | "size" : "32x32" 102 | }, 103 | { 104 | "idiom" : "mac", 105 | "scale" : "2x", 106 | "size" : "32x32" 107 | }, 108 | { 109 | "idiom" : "mac", 110 | "scale" : "1x", 111 | "size" : "128x128" 112 | }, 113 | { 114 | "idiom" : "mac", 115 | "scale" : "2x", 116 | "size" : "128x128" 117 | }, 118 | { 119 | "idiom" : "mac", 120 | "scale" : "1x", 121 | "size" : "256x256" 122 | }, 123 | { 124 | "idiom" : "mac", 125 | "scale" : "2x", 126 | "size" : "256x256" 127 | }, 128 | { 129 | "idiom" : "mac", 130 | "scale" : "1x", 131 | "size" : "512x512" 132 | }, 133 | { 134 | "idiom" : "mac", 135 | "scale" : "2x", 136 | "size" : "512x512" 137 | } 138 | ], 139 | "info" : { 140 | "author" : "xcode", 141 | "version" : 1 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/Flow/Views/NodeEditor+Modifiers.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import SwiftUI 4 | 5 | // View Modifiers 6 | 7 | public extension NodeEditor { 8 | // MARK: - Event Handlers 9 | 10 | /// Called when a node is moved. 11 | func onNodeMoved(_ handler: @escaping NodeMovedHandler) -> Self { 12 | var viewCopy = self 13 | viewCopy.nodeMoved = handler 14 | return viewCopy 15 | } 16 | 17 | /// Called when a wire is added. 18 | func onWireAdded(_ handler: @escaping WireAddedHandler) -> Self { 19 | var viewCopy = self 20 | viewCopy.wireAdded = handler 21 | return viewCopy 22 | } 23 | 24 | /// Called when a wire is removed. 25 | func onWireRemoved(_ handler: @escaping WireRemovedHandler) -> Self { 26 | var viewCopy = self 27 | viewCopy.wireRemoved = handler 28 | return viewCopy 29 | } 30 | 31 | /// Called when the viewing transform has changed. 32 | func onTransformChanged(_ handler: @escaping TransformChangedHandler) -> Self { 33 | var viewCopy = self 34 | viewCopy.transformChanged = handler 35 | return viewCopy 36 | } 37 | 38 | // MARK: - Style Modifiers 39 | 40 | /// Set the node color. 41 | func nodeColor(_ color: Color) -> Self { 42 | var viewCopy = self 43 | viewCopy.style.nodeColor = color 44 | return viewCopy 45 | } 46 | 47 | /// Set the port color for a port type. 48 | func portColor(for portType: PortType, _ color: Color) -> Self { 49 | var viewCopy = self 50 | 51 | switch portType { 52 | case .control: 53 | viewCopy.style.controlWire.inputColor = color 54 | viewCopy.style.controlWire.outputColor = color 55 | case .signal: 56 | viewCopy.style.signalWire.inputColor = color 57 | viewCopy.style.signalWire.outputColor = color 58 | case .midi: 59 | viewCopy.style.midiWire.inputColor = color 60 | viewCopy.style.midiWire.outputColor = color 61 | case let .custom(id): 62 | if viewCopy.style.customWires[id] == nil { 63 | viewCopy.style.customWires[id] = .init() 64 | } 65 | viewCopy.style.customWires[id]?.inputColor = color 66 | viewCopy.style.customWires[id]?.outputColor = color 67 | } 68 | 69 | return viewCopy 70 | } 71 | 72 | /// Set the port color for a port type to a gradient. 73 | func portColor(for portType: PortType, _ gradient: Gradient) -> Self { 74 | var viewCopy = self 75 | 76 | switch portType { 77 | case .control: 78 | viewCopy.style.controlWire.gradient = gradient 79 | case .signal: 80 | viewCopy.style.signalWire.gradient = gradient 81 | case .midi: 82 | viewCopy.style.midiWire.gradient = gradient 83 | case let .custom(id): 84 | if viewCopy.style.customWires[id] == nil { 85 | viewCopy.style.customWires[id] = .init() 86 | } 87 | viewCopy.style.customWires[id]?.gradient = gradient 88 | } 89 | 90 | return viewCopy 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/Flow/Model/Patch+Layout.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import CoreGraphics 4 | import Foundation 5 | 6 | public extension Patch { 7 | /// Recursive layout. 8 | /// 9 | /// - Returns: Height of all nodes in subtree. 10 | @discardableResult 11 | mutating func recursiveLayout( 12 | nodeIndex: NodeIndex, 13 | at point: CGPoint, 14 | layout: LayoutConstants = LayoutConstants(), 15 | consumedNodeIndexes: Set = [], 16 | nodePadding: Bool = false 17 | ) -> (aggregateHeight: CGFloat, 18 | consumedNodeIndexes: Set) 19 | { 20 | nodes[nodeIndex].position = point 21 | 22 | // XXX: super slow 23 | let incomingWires = wires.filter { 24 | $0.input.nodeIndex == nodeIndex 25 | }.sorted(by: { $0.input.portIndex < $1.input.portIndex }) 26 | 27 | var consumedNodeIndexes = consumedNodeIndexes 28 | 29 | var height: CGFloat = 0 30 | for wire in incomingWires { 31 | let addPadding = wire == incomingWires.last 32 | let ni = wire.output.nodeIndex 33 | guard !consumedNodeIndexes.contains(ni) else { continue } 34 | let rl = recursiveLayout(nodeIndex: ni, 35 | at: CGPoint(x: point.x - layout.nodeWidth - layout.nodeSpacing, 36 | y: point.y + height), 37 | layout: layout, 38 | consumedNodeIndexes: consumedNodeIndexes, 39 | nodePadding: addPadding) 40 | height = rl.aggregateHeight 41 | consumedNodeIndexes.insert(ni) 42 | consumedNodeIndexes.formUnion(rl.consumedNodeIndexes) 43 | } 44 | 45 | let nodeHeight = nodes[nodeIndex].rect(layout: layout).height 46 | let aggregateHeight = max(height, nodeHeight) + (nodePadding ? layout.nodeSpacing : 0) 47 | return (aggregateHeight: aggregateHeight, 48 | consumedNodeIndexes: consumedNodeIndexes) 49 | } 50 | 51 | /// Manual stacked grid layout. 52 | /// 53 | /// - Parameters: 54 | /// - origin: Top-left origin coordinate. 55 | /// - columns: Array of columns each comprised of an array of node indexes. 56 | /// - layout: Layout constants. 57 | mutating func stackedLayout(at origin: CGPoint = .zero, 58 | _ columns: [[NodeIndex]], 59 | layout: LayoutConstants = LayoutConstants()) 60 | { 61 | for column in columns.indices { 62 | let nodeStack = columns[column] 63 | var yOffset: CGFloat = 0 64 | 65 | let xPos = origin.x + (CGFloat(column) * (layout.nodeWidth + layout.nodeSpacing)) 66 | for nodeIndex in nodeStack { 67 | nodes[nodeIndex].position = .init( 68 | x: xPos, 69 | y: origin.y + yOffset 70 | ) 71 | 72 | let nodeHeight = nodes[nodeIndex].rect(layout: layout).height 73 | yOffset += nodeHeight 74 | if column != columns.indices.last { 75 | yOffset += layout.nodeSpacing 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Flow/Views/NodeEditor.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import SwiftUI 4 | 5 | /// Draws and interacts with the patch. 6 | /// 7 | /// Draws everything using a single Canvas with manual layout. We found this is faster than 8 | /// using a View for each Node. 9 | public struct NodeEditor: View { 10 | /// Data model. 11 | @Binding var patch: Patch 12 | 13 | /// Selected nodes. 14 | @Binding var selection: Set 15 | 16 | /// State for all gestures. 17 | @GestureState var dragInfo = DragInfo.none 18 | 19 | /// Cache resolved text 20 | @StateObject var textCache = TextCache() 21 | 22 | /// Node moved handler closure. 23 | public typealias NodeMovedHandler = (_ index: NodeIndex, 24 | _ location: CGPoint) -> Void 25 | 26 | /// Called when a node is moved. 27 | var nodeMoved: NodeMovedHandler = { _, _ in } 28 | 29 | /// Wire added handler closure. 30 | public typealias WireAddedHandler = (_ wire: Wire) -> Void 31 | 32 | /// Called when a wire is added. 33 | var wireAdded: WireAddedHandler = { _ in } 34 | 35 | /// Wire removed handler closure. 36 | public typealias WireRemovedHandler = (_ wire: Wire) -> Void 37 | 38 | /// Called when a wire is removed. 39 | var wireRemoved: WireRemovedHandler = { _ in } 40 | 41 | /// Handler for pan or zoom. 42 | public typealias TransformChangedHandler = (_ pan: CGSize, _ zoom: CGFloat) -> Void 43 | 44 | /// Called when the patch is panned or zoomed. 45 | var transformChanged: TransformChangedHandler = { _, _ in } 46 | 47 | /// Initialize the patch view with a patch and a selection. 48 | /// 49 | /// To define event handlers, chain their view modifiers: ``onNodeMoved(_:)``, ``onWireAdded(_:)``, ``onWireRemoved(_:)``. 50 | /// 51 | /// - Parameters: 52 | /// - patch: Patch to display. 53 | /// - selection: Set of nodes currently selected. 54 | public init(patch: Binding, 55 | selection: Binding>, 56 | layout: LayoutConstants = LayoutConstants()) 57 | { 58 | _patch = patch 59 | _selection = selection 60 | self.layout = layout 61 | } 62 | 63 | /// Constants used for layout. 64 | var layout: LayoutConstants 65 | 66 | /// Configuration used to determine rendering style. 67 | public var style = Style() 68 | 69 | @State var pan: CGSize = .zero 70 | @State var zoom: Double = 1 71 | @State var mousePosition: CGPoint = CGPoint(x: CGFloat.infinity, y: CGFloat.infinity) 72 | 73 | public var body: some View { 74 | ZStack { 75 | Canvas { cx, size in 76 | 77 | let viewport = CGRect(origin: toLocal(.zero), size: toLocal(size)) 78 | cx.addFilter(.shadow(radius: 5)) 79 | 80 | cx.scaleBy(x: CGFloat(zoom), y: CGFloat(zoom)) 81 | cx.translateBy(x: pan.width, y: pan.height) 82 | 83 | self.drawWires(cx: cx, viewport: viewport) 84 | self.drawNodes(cx: cx, viewport: viewport) 85 | self.drawDraggedWire(cx: cx) 86 | self.drawSelectionRect(cx: cx) 87 | } 88 | WorkspaceView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) 89 | #if os(macOS) 90 | .gesture(commandGesture) 91 | #endif 92 | .gesture(dragGesture) 93 | } 94 | .onChange(of: pan) { newValue in 95 | transformChanged(newValue, zoom) 96 | } 97 | .onChange(of: zoom) { newValue in 98 | transformChanged(pan, newValue) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/Flow/Views/NodeEditor+Gestures.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import SwiftUI 4 | 5 | extension NodeEditor { 6 | /// State for all gestures. 7 | enum DragInfo { 8 | case wire(output: OutputID, offset: CGSize = .zero, hideWire: Wire? = nil) 9 | case node(index: NodeIndex, offset: CGSize = .zero) 10 | case selection(rect: CGRect = .zero) 11 | case none 12 | } 13 | 14 | /// Adds a new wire to the patch, ensuring that multiple wires aren't connected to an input. 15 | func connect(_ output: OutputID, to input: InputID) { 16 | let wire = Wire(from: output, to: input) 17 | 18 | // Remove any other wires connected to the input. 19 | patch.wires = patch.wires.filter { w in 20 | let result = w.input != wire.input 21 | if !result { 22 | wireRemoved(w) 23 | } 24 | return result 25 | } 26 | patch.wires.insert(wire) 27 | wireAdded(wire) 28 | } 29 | 30 | func attachedWire(inputID: InputID) -> Wire? { 31 | patch.wires.first(where: { $0.input == inputID }) 32 | } 33 | 34 | func toLocal(_ p: CGPoint) -> CGPoint { 35 | CGPoint(x: p.x / CGFloat(zoom), y: p.y / CGFloat(zoom)) - pan 36 | } 37 | 38 | func toLocal(_ sz: CGSize) -> CGSize { 39 | CGSize(width: sz.width / CGFloat(zoom), height: sz.height / CGFloat(zoom)) 40 | } 41 | 42 | #if os(macOS) 43 | var commandGesture: some Gesture { 44 | DragGesture(minimumDistance: 0).modifiers(.command).onEnded { drag in 45 | guard drag.distance < 5 else { return } 46 | 47 | let startLocation = toLocal(drag.startLocation) 48 | 49 | let hitResult = patch.hitTest(point: startLocation, layout: layout) 50 | switch hitResult { 51 | case .none: 52 | return 53 | case let .node(nodeIndex): 54 | if selection.contains(nodeIndex) { 55 | selection.remove(nodeIndex) 56 | } else { 57 | selection.insert(nodeIndex) 58 | } 59 | default: break 60 | } 61 | } 62 | } 63 | #endif 64 | 65 | var dragGesture: some Gesture { 66 | DragGesture(minimumDistance: 0) 67 | .updating($dragInfo) { drag, dragInfo, _ in 68 | 69 | let startLocation = toLocal(drag.startLocation) 70 | let location = toLocal(drag.location) 71 | let translation = toLocal(drag.translation) 72 | 73 | switch patch.hitTest(point: startLocation, layout: layout) { 74 | case .none: 75 | dragInfo = .selection(rect: CGRect(a: startLocation, 76 | b: location)) 77 | case let .node(nodeIndex): 78 | dragInfo = .node(index: nodeIndex, offset: translation) 79 | case let .output(nodeIndex, portIndex): 80 | dragInfo = DragInfo.wire(output: OutputID(nodeIndex, portIndex), offset: translation) 81 | case let .input(nodeIndex, portIndex): 82 | let node = patch.nodes[nodeIndex] 83 | // Is a wire attached to the input? 84 | if let attachedWire = attachedWire(inputID: InputID(nodeIndex, portIndex)) { 85 | let offset = node.inputRect(input: portIndex, layout: layout).center 86 | - patch.nodes[attachedWire.output.nodeIndex].outputRect( 87 | output: attachedWire.output.portIndex, 88 | layout: layout 89 | ).center 90 | + translation 91 | dragInfo = .wire(output: attachedWire.output, 92 | offset: offset, 93 | hideWire: attachedWire) 94 | } 95 | } 96 | } 97 | .onEnded { drag in 98 | 99 | let startLocation = toLocal(drag.startLocation) 100 | let location = toLocal(drag.location) 101 | let translation = toLocal(drag.translation) 102 | 103 | let hitResult = patch.hitTest(point: startLocation, layout: layout) 104 | 105 | // Note that this threshold should be in screen coordinates. 106 | if drag.distance > 5 { 107 | switch hitResult { 108 | case .none: 109 | let selectionRect = CGRect(a: startLocation, b: location) 110 | selection = self.patch.selected( 111 | in: selectionRect, 112 | layout: layout 113 | ) 114 | case let .node(nodeIndex): 115 | patch.moveNode( 116 | nodeIndex: nodeIndex, 117 | offset: translation, 118 | nodeMoved: self.nodeMoved 119 | ) 120 | if selection.contains(nodeIndex) { 121 | for idx in selection where idx != nodeIndex { 122 | patch.moveNode( 123 | nodeIndex: idx, 124 | offset: translation, 125 | nodeMoved: self.nodeMoved 126 | ) 127 | } 128 | } 129 | case let .output(nodeIndex, portIndex): 130 | let type = patch.nodes[nodeIndex].outputs[portIndex].type 131 | if let input = findInput(point: location, type: type) { 132 | connect(OutputID(nodeIndex, portIndex), to: input) 133 | } 134 | case let .input(nodeIndex, portIndex): 135 | let type = patch.nodes[nodeIndex].inputs[portIndex].type 136 | // Is a wire attached to the input? 137 | if let attachedWire = attachedWire(inputID: InputID(nodeIndex, portIndex)) { 138 | patch.wires.remove(attachedWire) 139 | wireRemoved(attachedWire) 140 | if let input = findInput(point: location, type: type) { 141 | connect(attachedWire.output, to: input) 142 | } 143 | } 144 | } 145 | } else { 146 | // If we haven't moved far, then this is effectively a tap. 147 | switch hitResult { 148 | case .none: 149 | selection = Set() 150 | case let .node(nodeIndex): 151 | selection = Set([nodeIndex]) 152 | default: break 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | extension DragGesture.Value { 160 | @inlinable @inline(__always) 161 | var distance: CGFloat { 162 | startLocation.distance(to: location) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Sources/Flow/Views/WorkspaceView.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import SwiftUI 4 | 5 | /// Provides pan and zoom gestures. Unfortunately it seems this 6 | /// can't be accomplished using purely SwiftUI because MagnificationGesture 7 | /// doesn't provide a center point. 8 | #if os(iOS) || os(visionOS) 9 | struct WorkspaceView: UIViewRepresentable { 10 | @Binding var pan: CGSize 11 | @Binding var zoom: Double 12 | @Binding var mousePosition: CGPoint 13 | 14 | class Coordinator: NSObject { 15 | @Binding var pan: CGSize 16 | @Binding var zoom: Double 17 | 18 | init(pan: Binding, zoom: Binding) { 19 | _pan = pan 20 | _zoom = zoom 21 | } 22 | 23 | @objc func panGesture(sender: UIPanGestureRecognizer) { 24 | let t = sender.translation(in: nil) 25 | pan.width += t.x / zoom 26 | pan.height += t.y / zoom 27 | 28 | // Reset translation. 29 | sender.setTranslation(CGPoint.zero, in: nil) 30 | } 31 | 32 | @objc func zoomGesture(sender: UIPinchGestureRecognizer) { 33 | let p = sender.location(in: nil).size 34 | 35 | let newZoom = sender.scale * zoom 36 | 37 | let pLocal = p * (1.0 / zoom) - pan 38 | let newPan = p * (1.0 / newZoom) - pLocal 39 | 40 | pan = newPan 41 | zoom = newZoom 42 | 43 | // Reset scale. 44 | sender.scale = 1.0 45 | } 46 | } 47 | 48 | func makeCoordinator() -> Coordinator { 49 | Coordinator(pan: $pan, zoom: $zoom) 50 | } 51 | 52 | func makeUIView(context: Context) -> UIView { 53 | let view = UIView() 54 | 55 | let coordinator = context.coordinator 56 | 57 | let panRecognizer = UIPanGestureRecognizer(target: coordinator, 58 | action: #selector(Coordinator.panGesture(sender:))) 59 | view.addGestureRecognizer(panRecognizer) 60 | panRecognizer.delegate = coordinator 61 | panRecognizer.minimumNumberOfTouches = 2 62 | 63 | let pinchGesture = UIPinchGestureRecognizer(target: coordinator, action: 64 | #selector(Coordinator.zoomGesture(sender:))) 65 | view.addGestureRecognizer(pinchGesture) 66 | pinchGesture.delegate = coordinator 67 | 68 | return view 69 | } 70 | 71 | func updateUIView(_: UIView, context _: Context) { 72 | // Do nothing. 73 | } 74 | } 75 | 76 | extension WorkspaceView.Coordinator: UIGestureRecognizerDelegate { 77 | func gestureRecognizer(_: UIGestureRecognizer, 78 | shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool 79 | { 80 | return true 81 | } 82 | } 83 | 84 | #else 85 | 86 | class PanView: NSView { 87 | private static let defaultPanSpeed = 5.0 88 | 89 | @Binding var pan: CGSize 90 | @Binding var zoom: Double 91 | @Binding var mousePosition: CGPoint 92 | 93 | var trackingArea: NSTrackingArea! 94 | private var panSpeed = PanView.defaultPanSpeed 95 | 96 | init(pan: Binding, zoom: Binding, mousePosition: Binding) { 97 | _pan = pan 98 | _zoom = zoom 99 | _mousePosition = mousePosition 100 | 101 | super.init(frame: .zero) 102 | 103 | let panRecognizer = NSPanGestureRecognizer(target: self, 104 | action: #selector(PanView.panGesture(sender:))) 105 | addGestureRecognizer(panRecognizer) 106 | panRecognizer.buttonMask = 2 107 | panRecognizer.delegate = self 108 | 109 | let optionPanRecognizer = NSPanGestureRecognizer(target: self, 110 | action: #selector(PanView.panGesture(sender:))) 111 | addGestureRecognizer(optionPanRecognizer) 112 | optionPanRecognizer.delegate = self 113 | self.optionPanRecognizer = optionPanRecognizer 114 | 115 | let zoomRecognizer = NSMagnificationGestureRecognizer(target: self, 116 | action: #selector(PanView.zoomGesture(sender:))) 117 | addGestureRecognizer(zoomRecognizer) 118 | zoomRecognizer.delegate = self 119 | 120 | 121 | } 122 | 123 | required init?(coder: NSCoder) { 124 | fatalError("init(coder:) has not been implemented") 125 | } 126 | 127 | deinit { 128 | if trackingArea != nil { 129 | removeTrackingArea(trackingArea) 130 | } 131 | } 132 | 133 | override func updateTrackingAreas() { 134 | super.updateTrackingAreas() 135 | 136 | if trackingArea != nil { 137 | removeTrackingArea(trackingArea) 138 | } 139 | 140 | trackingArea = NSTrackingArea(rect: frame, options: [.mouseEnteredAndExited, .mouseMoved, .activeInActiveApp], owner: self, userInfo: nil) 141 | 142 | addTrackingArea(trackingArea) 143 | } 144 | 145 | override func scrollWheel(with event: NSEvent) { 146 | var p = convert(event.locationInWindow, from: nil).size 147 | p.height = frame.size.height - p.height 148 | 149 | if event.subtype == .mouseEvent { 150 | let rot = event.deltaY 151 | let scale = rot > 0 ? (1 + rot / 10) : 1.0/(1 - rot/10) 152 | zoom(at: p, scale: scale) 153 | 154 | } else { 155 | if event.momentumPhase == .changed { 156 | panSpeed *= 0.95 157 | } else { 158 | panSpeed = PanView.defaultPanSpeed 159 | } 160 | 161 | pan.width += panSpeed * event.deltaX / zoom 162 | pan.height += panSpeed * event.deltaY / zoom 163 | } 164 | } 165 | 166 | @objc func panGesture(sender: NSPanGestureRecognizer) { 167 | let t = sender.translation(in: self) 168 | pan.width += t.x / zoom 169 | pan.height -= t.y / zoom 170 | 171 | // Reset translation. 172 | sender.setTranslation(CGPoint.zero, in: nil) 173 | } 174 | 175 | @objc func zoomGesture(sender: NSMagnificationGestureRecognizer) { 176 | 177 | if sender.state == .changed { 178 | var p = sender.location(in: self).size 179 | p.height = frame.size.height - p.height 180 | 181 | zoom(at: p, scale: sender.magnification) 182 | } 183 | 184 | // Reset scale. 185 | sender.magnification = 1.0 186 | } 187 | 188 | func zoom(at p: CGSize, scale: CGFloat) { 189 | let newZoom = scale * zoom 190 | 191 | let pLocal = p * (1.0 / zoom) - pan 192 | let newPan = p * (1.0 / newZoom) - pLocal 193 | 194 | pan = newPan 195 | zoom = newZoom 196 | } 197 | 198 | override func mouseMoved(with event: NSEvent) { 199 | var p = convert(event.locationInWindow, from: nil) 200 | p.y = frame.size.height - p.y 201 | mousePosition = p 202 | } 203 | 204 | weak var optionPanRecognizer: NSGestureRecognizer? 205 | } 206 | 207 | extension PanView: NSGestureRecognizerDelegate { 208 | 209 | func gestureRecognizerShouldBegin(_ gestureRecognizer: NSGestureRecognizer) -> Bool { 210 | if gestureRecognizer == optionPanRecognizer { 211 | return NSEvent.modifierFlags == .option 212 | } 213 | return true 214 | } 215 | 216 | func gestureRecognizer(_: NSGestureRecognizer, 217 | shouldRecognizeSimultaneouslyWith _: NSGestureRecognizer) -> Bool 218 | { 219 | return true 220 | } 221 | } 222 | 223 | /// Handles gestures for changing the workspace view. 224 | struct WorkspaceView: NSViewRepresentable { 225 | @Binding var pan: CGSize 226 | @Binding var zoom: Double 227 | 228 | /// Current mouse position. Note that we can't get this from hovering 229 | /// in SwiftUI. `onHover` doesn't provide a position. 230 | @Binding var mousePosition: CGPoint 231 | 232 | func makeNSView(context: Context) -> NSView { 233 | return PanView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) 234 | } 235 | 236 | func updateNSView(_: NSView, context _: Context) { 237 | // Do nothing. 238 | } 239 | } 240 | 241 | #endif 242 | 243 | struct WorkspaceTestView: View { 244 | @State var pan: CGSize = .zero 245 | @State var zoom: Double = 0.0 246 | @State var mousePosition: CGPoint = .zero 247 | 248 | var body: some View { 249 | WorkspaceView(pan: $pan, zoom: $zoom, mousePosition: $mousePosition) 250 | } 251 | } 252 | 253 | struct WorkspaceView_Previews: PreviewProvider { 254 | static var previews: some View { 255 | WorkspaceTestView() 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /Sources/Flow/Views/NodeEditor+Drawing.swift: -------------------------------------------------------------------------------- 1 | // Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/Flow/ 2 | 3 | import SwiftUI 4 | 5 | extension GraphicsContext { 6 | @inlinable @inline(__always) 7 | func drawDot(in rect: CGRect, with shading: Shading) { 8 | let dot = Path(ellipseIn: rect.insetBy(dx: rect.size.width / 3, dy: rect.size.height / 3)) 9 | fill(dot, with: shading) 10 | } 11 | 12 | 13 | 14 | func strokeWire( 15 | from: CGPoint, 16 | to: CGPoint, 17 | gradient: Gradient 18 | ) { 19 | let d = 0.4 * abs(to.x - from.x) 20 | var path = Path() 21 | path.move(to: from) 22 | path.addCurve( 23 | to: to, 24 | control1: CGPoint(x: from.x + d, y: from.y), 25 | control2: CGPoint(x: to.x - d, y: to.y) 26 | ) 27 | 28 | stroke( 29 | path, 30 | with: .linearGradient(gradient, startPoint: from, endPoint: to), 31 | style: StrokeStyle(lineWidth: 2.0, lineCap: .round) 32 | ) 33 | } 34 | } 35 | 36 | extension NodeEditor { 37 | @inlinable @inline(__always) 38 | func color(for type: PortType, isOutput: Bool) -> Color { 39 | style.color(for: type, isOutput: isOutput) ?? .gray 40 | } 41 | 42 | func drawInputPort( 43 | cx: GraphicsContext, 44 | node: Node, 45 | index: Int, 46 | offset: CGSize, 47 | portShading: GraphicsContext.Shading, 48 | isConnected: Bool 49 | ) { 50 | let rect = node.inputRect(input: index, layout: layout).offset(by: offset) 51 | let circle = Path(ellipseIn: rect) 52 | let port = node.inputs[index] 53 | 54 | cx.fill(circle, with: portShading) 55 | 56 | if !isConnected { 57 | cx.drawDot(in: rect, with: .color(.black)) 58 | } else if rect.contains(toLocal(mousePosition)) { 59 | cx.stroke(circle, with: .color(.white), style: .init(lineWidth: 1.0)) 60 | } 61 | 62 | cx.draw( 63 | textCache.text(string: port.name, font: layout.portNameFont, cx), 64 | at: rect.center + CGSize(width: layout.portSize.width / 2 + layout.portSpacing, height: 0), 65 | anchor: .leading 66 | ) 67 | } 68 | 69 | func drawOutputPort( 70 | cx: GraphicsContext, 71 | node: Node, 72 | index: Int, 73 | offset: CGSize, 74 | portShading: GraphicsContext.Shading, 75 | isConnected: Bool 76 | ) { 77 | let rect = node.outputRect(output: index, layout: layout).offset(by: offset) 78 | let circle = Path(ellipseIn: rect) 79 | let port = node.outputs[index] 80 | 81 | cx.fill(circle, with: portShading) 82 | 83 | if !isConnected { 84 | cx.drawDot(in: rect, with: .color(.black)) 85 | } 86 | 87 | if rect.contains(toLocal(mousePosition)) { 88 | cx.stroke(circle, with: .color(.white), style: .init(lineWidth: 1.0)) 89 | } 90 | 91 | cx.draw(textCache.text(string: port.name, font: layout.portNameFont, cx), 92 | at: rect.center + CGSize(width: -(layout.portSize.width / 2 + layout.portSpacing), height: 0), 93 | anchor: .trailing) 94 | } 95 | 96 | func inputShading(_ type: PortType, _ colors: inout [PortType: GraphicsContext.Shading], _ cx: GraphicsContext) -> GraphicsContext.Shading { 97 | if let shading = colors[type] { 98 | return shading 99 | } 100 | let shading = cx.resolve(.color(color(for: type, isOutput: false))) 101 | colors[type] = shading 102 | return shading 103 | } 104 | 105 | func outputShading(_ type: PortType, _ colors: inout [PortType: GraphicsContext.Shading], _ cx: GraphicsContext) -> GraphicsContext.Shading { 106 | if let shading = colors[type] { 107 | return shading 108 | } 109 | let shading = cx.resolve(.color(color(for: type, isOutput: true))) 110 | colors[type] = shading 111 | return shading 112 | } 113 | 114 | func drawNodes(cx: GraphicsContext, viewport: CGRect) { 115 | 116 | let connectedInputs = Set( patch.wires.map { wire in wire.input } ) 117 | let connectedOutputs = Set( patch.wires.map { wire in wire.output } ) 118 | 119 | let selectedShading = cx.resolve(.color(style.nodeColor.opacity(0.8))) 120 | let unselectedShading = cx.resolve(.color(style.nodeColor.opacity(0.4))) 121 | 122 | var resolvedInputColors = [PortType: GraphicsContext.Shading]() 123 | var resolvedOutputColors = [PortType: GraphicsContext.Shading]() 124 | 125 | for (nodeIndex, node) in patch.nodes.enumerated() { 126 | let offset = self.offset(for: nodeIndex) 127 | let rect = node.rect(layout: layout).offset(by: offset) 128 | 129 | guard rect.intersects(viewport) else { continue } 130 | 131 | let pos = rect.origin 132 | 133 | let cornerRadius = layout.nodeCornerRadius 134 | let bg = Path(roundedRect: rect, cornerRadius: cornerRadius) 135 | 136 | var selected = false 137 | switch dragInfo { 138 | case let .selection(rect: selectionRect): 139 | selected = rect.intersects(selectionRect) 140 | default: 141 | selected = selection.contains(nodeIndex) 142 | } 143 | 144 | cx.fill(bg, with: selected ? selectedShading : unselectedShading) 145 | 146 | // Draw the title bar for the node. There seems to be 147 | // no better cross-platform way to render a rectangle with the top 148 | // two cornders rounded. 149 | var titleBar = Path() 150 | titleBar.move(to: CGPoint(x: 0, y: layout.nodeTitleHeight) + rect.origin.size) 151 | titleBar.addLine(to: CGPoint(x: 0, y: cornerRadius) + rect.origin.size) 152 | titleBar.addRelativeArc(center: CGPoint(x: cornerRadius, y: cornerRadius) + rect.origin.size, 153 | radius: cornerRadius, 154 | startAngle: .degrees(180), 155 | delta: .degrees(90)) 156 | titleBar.addLine(to: CGPoint(x: layout.nodeWidth - cornerRadius, y: 0) + rect.origin.size) 157 | titleBar.addRelativeArc(center: CGPoint(x: layout.nodeWidth - cornerRadius, y: cornerRadius) + rect.origin.size, 158 | radius: cornerRadius, 159 | startAngle: .degrees(270), 160 | delta: .degrees(90)) 161 | titleBar.addLine(to: CGPoint(x: layout.nodeWidth, y: layout.nodeTitleHeight) + rect.origin.size) 162 | titleBar.closeSubpath() 163 | 164 | cx.fill(titleBar, with: .color(node.titleBarColor)) 165 | 166 | if rect.contains(toLocal(mousePosition)) { 167 | cx.stroke(bg, with: .color(.white), style: .init(lineWidth: 1.0)) 168 | } 169 | 170 | cx.draw(textCache.text(string: node.name, font: layout.nodeTitleFont, cx), 171 | at: pos + CGSize(width: rect.size.width / 2, height: layout.nodeTitleHeight / 2), 172 | anchor: .center) 173 | 174 | for (i, input) in node.inputs.enumerated() { 175 | drawInputPort( 176 | cx: cx, 177 | node: node, 178 | index: i, 179 | offset: offset, 180 | portShading: inputShading(input.type, &resolvedInputColors, cx), 181 | isConnected: connectedInputs.contains(InputID(nodeIndex, i)) 182 | ) 183 | } 184 | 185 | for (i, output) in node.outputs.enumerated() { 186 | drawOutputPort( 187 | cx: cx, 188 | node: node, 189 | index: i, 190 | offset: offset, 191 | portShading: outputShading(output.type, &resolvedOutputColors, cx), 192 | isConnected: connectedOutputs.contains(OutputID(nodeIndex, i)) 193 | ) 194 | } 195 | } 196 | } 197 | 198 | func drawWires(cx: GraphicsContext, viewport: CGRect) { 199 | var hideWire: Wire? 200 | switch dragInfo { 201 | case let .wire(_, _, hideWire: hw): 202 | hideWire = hw 203 | default: 204 | hideWire = nil 205 | } 206 | for wire in patch.wires where wire != hideWire { 207 | let fromPoint = self.patch.nodes[wire.output.nodeIndex].outputRect( 208 | output: wire.output.portIndex, 209 | layout: self.layout 210 | ) 211 | .offset(by: self.offset(for: wire.output.nodeIndex)).center 212 | 213 | let toPoint = self.patch.nodes[wire.input.nodeIndex].inputRect( 214 | input: wire.input.portIndex, 215 | layout: self.layout 216 | ) 217 | .offset(by: self.offset(for: wire.input.nodeIndex)).center 218 | 219 | let bounds = CGRect(origin: fromPoint, size: toPoint - fromPoint) 220 | if viewport.intersects(bounds) { 221 | let gradient = self.gradient(for: wire) 222 | cx.strokeWire(from: fromPoint, to: toPoint, gradient: gradient) 223 | } 224 | } 225 | } 226 | 227 | func drawDraggedWire(cx: GraphicsContext) { 228 | if case let .wire(output: output, offset: offset, _) = dragInfo { 229 | let outputRect = self.patch 230 | .nodes[output.nodeIndex] 231 | .outputRect(output: output.portIndex, layout: self.layout) 232 | let gradient = self.gradient(for: output) 233 | cx.strokeWire(from: outputRect.center, to: outputRect.center + offset, gradient: gradient) 234 | } 235 | } 236 | 237 | func drawSelectionRect(cx: GraphicsContext) { 238 | if case let .selection(rect: rect) = dragInfo { 239 | let rectPath = Path(roundedRect: rect, cornerRadius: 0) 240 | cx.stroke(rectPath, with: .color(.cyan)) 241 | } 242 | } 243 | 244 | func gradient(for outputID: OutputID) -> Gradient { 245 | let portType = patch 246 | .nodes[outputID.nodeIndex] 247 | .outputs[outputID.portIndex] 248 | .type 249 | return style.gradient(for: portType) ?? .init(colors: [.gray]) 250 | } 251 | 252 | func gradient(for wire: Wire) -> Gradient { 253 | gradient(for: wire.output) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /Demo/FlowDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | F1DDBABD28BE927D0009F000 /* FlowDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DDBAAD28BE927B0009F000 /* FlowDemoApp.swift */; }; 11 | F1DDBABF28BE927D0009F000 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DDBAAE28BE927B0009F000 /* ContentView.swift */; }; 12 | F1DDBAC128BE927D0009F000 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1DDBAAF28BE927D0009F000 /* Assets.xcassets */; }; 13 | F1DDBACF28BE92D00009F000 /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = F1DDBACE28BE92D00009F000 /* Flow */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXFileReference section */ 17 | F1DDBAAD28BE927B0009F000 /* FlowDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowDemoApp.swift; sourceTree = ""; }; 18 | F1DDBAAE28BE927B0009F000 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 19 | F1DDBAAF28BE927D0009F000 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 20 | F1DDBAB428BE927D0009F000 /* FlowDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FlowDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | F1DDBABC28BE927D0009F000 /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = ""; }; 22 | F1DDBACC28BE92A30009F000 /* Flow */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Flow; path = ..; sourceTree = ""; }; 23 | /* End PBXFileReference section */ 24 | 25 | /* Begin PBXFrameworksBuildPhase section */ 26 | F1DDBAB128BE927D0009F000 /* Frameworks */ = { 27 | isa = PBXFrameworksBuildPhase; 28 | buildActionMask = 2147483647; 29 | files = ( 30 | F1DDBACF28BE92D00009F000 /* Flow in Frameworks */, 31 | ); 32 | runOnlyForDeploymentPostprocessing = 0; 33 | }; 34 | /* End PBXFrameworksBuildPhase section */ 35 | 36 | /* Begin PBXGroup section */ 37 | F1DDBAA728BE927B0009F000 = { 38 | isa = PBXGroup; 39 | children = ( 40 | F1DDBACB28BE92A30009F000 /* Packages */, 41 | F1DDBAAC28BE927B0009F000 /* Shared */, 42 | F1DDBABB28BE927D0009F000 /* macOS */, 43 | F1DDBAB528BE927D0009F000 /* Products */, 44 | F1DDBACD28BE92D00009F000 /* Frameworks */, 45 | ); 46 | sourceTree = ""; 47 | }; 48 | F1DDBAAC28BE927B0009F000 /* Shared */ = { 49 | isa = PBXGroup; 50 | children = ( 51 | F1DDBAAD28BE927B0009F000 /* FlowDemoApp.swift */, 52 | F1DDBAAE28BE927B0009F000 /* ContentView.swift */, 53 | F1DDBAAF28BE927D0009F000 /* Assets.xcassets */, 54 | ); 55 | path = Shared; 56 | sourceTree = ""; 57 | }; 58 | F1DDBAB528BE927D0009F000 /* Products */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | F1DDBAB428BE927D0009F000 /* FlowDemo.app */, 62 | ); 63 | name = Products; 64 | sourceTree = ""; 65 | }; 66 | F1DDBABB28BE927D0009F000 /* macOS */ = { 67 | isa = PBXGroup; 68 | children = ( 69 | F1DDBABC28BE927D0009F000 /* macOS.entitlements */, 70 | ); 71 | path = macOS; 72 | sourceTree = ""; 73 | }; 74 | F1DDBACB28BE92A30009F000 /* Packages */ = { 75 | isa = PBXGroup; 76 | children = ( 77 | F1DDBACC28BE92A30009F000 /* Flow */, 78 | ); 79 | name = Packages; 80 | sourceTree = ""; 81 | }; 82 | F1DDBACD28BE92D00009F000 /* Frameworks */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | ); 86 | name = Frameworks; 87 | sourceTree = ""; 88 | }; 89 | /* End PBXGroup section */ 90 | 91 | /* Begin PBXNativeTarget section */ 92 | F1DDBAB328BE927D0009F000 /* FlowDemo */ = { 93 | isa = PBXNativeTarget; 94 | buildConfigurationList = F1DDBAC528BE927D0009F000 /* Build configuration list for PBXNativeTarget "FlowDemo" */; 95 | buildPhases = ( 96 | F1DDBAB028BE927D0009F000 /* Sources */, 97 | F1DDBAB128BE927D0009F000 /* Frameworks */, 98 | F1DDBAB228BE927D0009F000 /* Resources */, 99 | ); 100 | buildRules = ( 101 | ); 102 | dependencies = ( 103 | ); 104 | name = FlowDemo; 105 | packageProductDependencies = ( 106 | F1DDBACE28BE92D00009F000 /* Flow */, 107 | ); 108 | productName = "FlowDemo (iOS)"; 109 | productReference = F1DDBAB428BE927D0009F000 /* FlowDemo.app */; 110 | productType = "com.apple.product-type.application"; 111 | }; 112 | /* End PBXNativeTarget section */ 113 | 114 | /* Begin PBXProject section */ 115 | F1DDBAA828BE927B0009F000 /* Project object */ = { 116 | isa = PBXProject; 117 | attributes = { 118 | BuildIndependentTargetsInParallel = 1; 119 | LastSwiftUpdateCheck = 1340; 120 | LastUpgradeCheck = 1340; 121 | TargetAttributes = { 122 | F1DDBAB328BE927D0009F000 = { 123 | CreatedOnToolsVersion = 13.4.1; 124 | }; 125 | }; 126 | }; 127 | buildConfigurationList = F1DDBAAB28BE927B0009F000 /* Build configuration list for PBXProject "FlowDemo" */; 128 | compatibilityVersion = "Xcode 13.0"; 129 | developmentRegion = en; 130 | hasScannedForEncodings = 0; 131 | knownRegions = ( 132 | en, 133 | Base, 134 | ); 135 | mainGroup = F1DDBAA728BE927B0009F000; 136 | productRefGroup = F1DDBAB528BE927D0009F000 /* Products */; 137 | projectDirPath = ""; 138 | projectRoot = ""; 139 | targets = ( 140 | F1DDBAB328BE927D0009F000 /* FlowDemo */, 141 | ); 142 | }; 143 | /* End PBXProject section */ 144 | 145 | /* Begin PBXResourcesBuildPhase section */ 146 | F1DDBAB228BE927D0009F000 /* Resources */ = { 147 | isa = PBXResourcesBuildPhase; 148 | buildActionMask = 2147483647; 149 | files = ( 150 | F1DDBAC128BE927D0009F000 /* Assets.xcassets in Resources */, 151 | ); 152 | runOnlyForDeploymentPostprocessing = 0; 153 | }; 154 | /* End PBXResourcesBuildPhase section */ 155 | 156 | /* Begin PBXSourcesBuildPhase section */ 157 | F1DDBAB028BE927D0009F000 /* Sources */ = { 158 | isa = PBXSourcesBuildPhase; 159 | buildActionMask = 2147483647; 160 | files = ( 161 | F1DDBABF28BE927D0009F000 /* ContentView.swift in Sources */, 162 | F1DDBABD28BE927D0009F000 /* FlowDemoApp.swift in Sources */, 163 | ); 164 | runOnlyForDeploymentPostprocessing = 0; 165 | }; 166 | /* End PBXSourcesBuildPhase section */ 167 | 168 | /* Begin XCBuildConfiguration section */ 169 | F1DDBAC328BE927D0009F000 /* Debug */ = { 170 | isa = XCBuildConfiguration; 171 | buildSettings = { 172 | ALWAYS_SEARCH_USER_PATHS = NO; 173 | CLANG_ANALYZER_NONNULL = YES; 174 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 175 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 176 | CLANG_ENABLE_MODULES = YES; 177 | CLANG_ENABLE_OBJC_ARC = YES; 178 | CLANG_ENABLE_OBJC_WEAK = YES; 179 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 180 | CLANG_WARN_BOOL_CONVERSION = YES; 181 | CLANG_WARN_COMMA = YES; 182 | CLANG_WARN_CONSTANT_CONVERSION = YES; 183 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 184 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 185 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 186 | CLANG_WARN_EMPTY_BODY = YES; 187 | CLANG_WARN_ENUM_CONVERSION = YES; 188 | CLANG_WARN_INFINITE_RECURSION = YES; 189 | CLANG_WARN_INT_CONVERSION = YES; 190 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 191 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 192 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 193 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 194 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 195 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 196 | CLANG_WARN_STRICT_PROTOTYPES = YES; 197 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 198 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 199 | CLANG_WARN_UNREACHABLE_CODE = YES; 200 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 201 | COPY_PHASE_STRIP = NO; 202 | DEBUG_INFORMATION_FORMAT = dwarf; 203 | ENABLE_STRICT_OBJC_MSGSEND = YES; 204 | ENABLE_TESTABILITY = YES; 205 | GCC_C_LANGUAGE_STANDARD = gnu11; 206 | GCC_DYNAMIC_NO_PIC = NO; 207 | GCC_NO_COMMON_BLOCKS = YES; 208 | GCC_OPTIMIZATION_LEVEL = 0; 209 | GCC_PREPROCESSOR_DEFINITIONS = ( 210 | "DEBUG=1", 211 | "$(inherited)", 212 | ); 213 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 214 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 215 | GCC_WARN_UNDECLARED_SELECTOR = YES; 216 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 217 | GCC_WARN_UNUSED_FUNCTION = YES; 218 | GCC_WARN_UNUSED_VARIABLE = YES; 219 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 220 | MACOSX_DEPLOYMENT_TARGET = 12.0; 221 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 222 | MTL_FAST_MATH = YES; 223 | ONLY_ACTIVE_ARCH = YES; 224 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 225 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 226 | }; 227 | name = Debug; 228 | }; 229 | F1DDBAC428BE927D0009F000 /* Release */ = { 230 | isa = XCBuildConfiguration; 231 | buildSettings = { 232 | ALWAYS_SEARCH_USER_PATHS = NO; 233 | CLANG_ANALYZER_NONNULL = YES; 234 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 235 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 236 | CLANG_ENABLE_MODULES = YES; 237 | CLANG_ENABLE_OBJC_ARC = YES; 238 | CLANG_ENABLE_OBJC_WEAK = YES; 239 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 240 | CLANG_WARN_BOOL_CONVERSION = YES; 241 | CLANG_WARN_COMMA = YES; 242 | CLANG_WARN_CONSTANT_CONVERSION = YES; 243 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 244 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 245 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 246 | CLANG_WARN_EMPTY_BODY = YES; 247 | CLANG_WARN_ENUM_CONVERSION = YES; 248 | CLANG_WARN_INFINITE_RECURSION = YES; 249 | CLANG_WARN_INT_CONVERSION = YES; 250 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 251 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 252 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 253 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 254 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 255 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 256 | CLANG_WARN_STRICT_PROTOTYPES = YES; 257 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 258 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 259 | CLANG_WARN_UNREACHABLE_CODE = YES; 260 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 261 | COPY_PHASE_STRIP = NO; 262 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 263 | ENABLE_NS_ASSERTIONS = NO; 264 | ENABLE_STRICT_OBJC_MSGSEND = YES; 265 | GCC_C_LANGUAGE_STANDARD = gnu11; 266 | GCC_NO_COMMON_BLOCKS = YES; 267 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 268 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 269 | GCC_WARN_UNDECLARED_SELECTOR = YES; 270 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 271 | GCC_WARN_UNUSED_FUNCTION = YES; 272 | GCC_WARN_UNUSED_VARIABLE = YES; 273 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 274 | MACOSX_DEPLOYMENT_TARGET = 12.0; 275 | MTL_ENABLE_DEBUG_INFO = NO; 276 | MTL_FAST_MATH = YES; 277 | SWIFT_COMPILATION_MODE = wholemodule; 278 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 279 | }; 280 | name = Release; 281 | }; 282 | F1DDBAC628BE927D0009F000 /* Debug */ = { 283 | isa = XCBuildConfiguration; 284 | buildSettings = { 285 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 286 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 287 | CODE_SIGN_IDENTITY = "Apple Development"; 288 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 289 | CODE_SIGN_STYLE = Automatic; 290 | CURRENT_PROJECT_VERSION = 1; 291 | DEVELOPMENT_TEAM = 9W69ZP8S5F; 292 | ENABLE_PREVIEWS = YES; 293 | GENERATE_INFOPLIST_FILE = YES; 294 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 295 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 296 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 297 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 298 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 299 | LD_RUNPATH_SEARCH_PATHS = ( 300 | "$(inherited)", 301 | "@executable_path/Frameworks", 302 | ); 303 | MARKETING_VERSION = 1.0; 304 | PRODUCT_BUNDLE_IDENTIFIER = io.audiokit.FlowDemo; 305 | PRODUCT_NAME = FlowDemo; 306 | PROVISIONING_PROFILE_SPECIFIER = ""; 307 | SDKROOT = iphoneos; 308 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 309 | SUPPORTS_MACCATALYST = NO; 310 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 311 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 312 | SWIFT_EMIT_LOC_STRINGS = YES; 313 | SWIFT_VERSION = 5.0; 314 | TARGETED_DEVICE_FAMILY = "1,2,7"; 315 | }; 316 | name = Debug; 317 | }; 318 | F1DDBAC728BE927D0009F000 /* Release */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 322 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 323 | CODE_SIGN_IDENTITY = "Apple Development"; 324 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 325 | CODE_SIGN_STYLE = Automatic; 326 | CURRENT_PROJECT_VERSION = 1; 327 | DEVELOPMENT_TEAM = 9W69ZP8S5F; 328 | ENABLE_PREVIEWS = YES; 329 | GENERATE_INFOPLIST_FILE = YES; 330 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 331 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 332 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 333 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 334 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 335 | LD_RUNPATH_SEARCH_PATHS = ( 336 | "$(inherited)", 337 | "@executable_path/Frameworks", 338 | ); 339 | MARKETING_VERSION = 1.0; 340 | PRODUCT_BUNDLE_IDENTIFIER = io.audiokit.FlowDemo; 341 | PRODUCT_NAME = FlowDemo; 342 | PROVISIONING_PROFILE_SPECIFIER = ""; 343 | SDKROOT = iphoneos; 344 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 345 | SUPPORTS_MACCATALYST = NO; 346 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 347 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 348 | SWIFT_EMIT_LOC_STRINGS = YES; 349 | SWIFT_VERSION = 5.0; 350 | TARGETED_DEVICE_FAMILY = "1,2,7"; 351 | VALIDATE_PRODUCT = YES; 352 | }; 353 | name = Release; 354 | }; 355 | /* End XCBuildConfiguration section */ 356 | 357 | /* Begin XCConfigurationList section */ 358 | F1DDBAAB28BE927B0009F000 /* Build configuration list for PBXProject "FlowDemo" */ = { 359 | isa = XCConfigurationList; 360 | buildConfigurations = ( 361 | F1DDBAC328BE927D0009F000 /* Debug */, 362 | F1DDBAC428BE927D0009F000 /* Release */, 363 | ); 364 | defaultConfigurationIsVisible = 0; 365 | defaultConfigurationName = Release; 366 | }; 367 | F1DDBAC528BE927D0009F000 /* Build configuration list for PBXNativeTarget "FlowDemo" */ = { 368 | isa = XCConfigurationList; 369 | buildConfigurations = ( 370 | F1DDBAC628BE927D0009F000 /* Debug */, 371 | F1DDBAC728BE927D0009F000 /* Release */, 372 | ); 373 | defaultConfigurationIsVisible = 0; 374 | defaultConfigurationName = Release; 375 | }; 376 | /* End XCConfigurationList section */ 377 | 378 | /* Begin XCSwiftPackageProductDependency section */ 379 | F1DDBACE28BE92D00009F000 /* Flow */ = { 380 | isa = XCSwiftPackageProductDependency; 381 | productName = Flow; 382 | }; 383 | /* End XCSwiftPackageProductDependency section */ 384 | }; 385 | rootObject = F1DDBAA828BE927B0009F000 /* Project object */; 386 | } 387 | --------------------------------------------------------------------------------