├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── SwiftNodeEditor.xcscheme ├── CAVEAT.md ├── Demo ├── Packages │ └── SwiftNodeEditor ├── SwiftNodeEditorDemoApp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── SwiftNodeEditorDemoApp.xcscheme └── SwiftNodeEditorDemoApp │ ├── App.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── SwiftNodeEditorDemo.entitlements ├── Documentation ├── Screen Recording 1.gif └── Screenshot 1.png ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── Playgrounds └── MyPlayground.playground │ ├── Contents.swift │ ├── Resources │ └── MyView.xib │ └── contents.xcplayground ├── README.md ├── Sources ├── SwiftNodeEditor │ ├── Interface.swift │ ├── Model.swift │ ├── NodeGraphEditorView.swift │ ├── OrderedIDSet.swift │ └── Support.swift └── SwiftNodeEditorDemo │ ├── Actions.swift │ ├── BasicPresentation.swift │ ├── Document.swift │ ├── Model.swift │ ├── NodeGraphEditorDemoView.swift │ ├── RadialPresentation.swift │ └── Support.swift └── Tests └── SwiftNodeEditorTests └── SwiftNodeEditorTests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: macos-15 13 | steps: 14 | - uses: maxim-lobanov/setup-xcode@v1 15 | with: 16 | xcode-version: 16 17 | - uses: actions/checkout@v3 18 | - name: Build 19 | run: swift build -v 20 | - name: Run tests 21 | run: swift test -v 22 | - name: Build Demo (macOS) 23 | run: cd Demo && -scheme 'SwiftNodeEditorDemoApp' -allowProvisioningUpdates -destination 'generic/platform=macOS' 24 | - name: Build Demo (iOS) 25 | run: cd Demo && -scheme 'SwiftNodeEditorDemoApp' -allowProvisioningUpdates -destination 'generic/platform=macOS' 26 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.7 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --disable andOperator 2 | --disable emptyBraces 3 | --disable fileHeader 4 | --disable redundantParens 5 | --disable trailingClosures 6 | --enable isEmpty 7 | 8 | --elseposition next-line 9 | --ifdef indent 10 | --patternlet inline 11 | --stripunusedargs closure-only 12 | --closingparen balanced 13 | --wraparguments preserve 14 | --wrapcollections before-first 15 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | analyzer_rules: 2 | - capture_variable 3 | - explicit_self 4 | - typesafe_array_init 5 | - unused_declaration 6 | - unused_import 7 | only_rules: 8 | - accessibility_label_for_image 9 | # - anonymous_argument_in_multiline_closure 10 | - anyobject_protocol 11 | - array_init 12 | - balanced_xctest_lifecycle 13 | - block_based_kvo 14 | - class_delegate_protocol 15 | - closing_brace 16 | - closure_body_length 17 | - closure_end_indentation 18 | # - closure_parameter_position 19 | - closure_spacing 20 | - collection_alignment 21 | # - colon 22 | - comma 23 | - comma_inheritance 24 | # - comment_spacing 25 | - compiler_protocol_init 26 | - computed_accessors_order 27 | - conditional_returns_on_newline 28 | - contains_over_filter_count 29 | - contains_over_filter_is_empty 30 | - contains_over_first_not_nil 31 | - contains_over_range_nil_comparison 32 | - control_statement 33 | - convenience_type 34 | - custom_rules 35 | - cyclomatic_complexity 36 | - deployment_target 37 | - discarded_notification_center_observer 38 | - discouraged_assert 39 | - discouraged_direct_init 40 | # - discouraged_none_name 41 | - discouraged_object_literal 42 | - discouraged_optional_boolean 43 | # - discouraged_optional_collection 44 | - duplicate_enum_cases 45 | - duplicate_imports 46 | - duplicated_key_in_dictionary_literal 47 | - dynamic_inline 48 | - empty_collection_literal 49 | - empty_count 50 | - empty_enum_arguments 51 | - empty_parameters 52 | # - empty_parentheses_with_trailing_closure 53 | - empty_string 54 | # - empty_xctest_method 55 | - enum_case_associated_values_count 56 | - expiring_todo 57 | # - explicit_acl 58 | - explicit_enum_raw_value 59 | - explicit_init 60 | # - explicit_top_level_acl 61 | # - explicit_type_interface 62 | - extension_access_modifier 63 | - fallthrough 64 | - fatal_error_message 65 | # - file_header 66 | - file_length 67 | # - file_name 68 | # - file_name_no_space 69 | # - file_types_order 70 | - first_where 71 | - flatmap_over_map_reduce 72 | # - for_where 73 | # - force_cast 74 | # - force_try 75 | # - force_unwrapping 76 | - function_body_length 77 | # - function_default_parameter_at_end 78 | - function_parameter_count 79 | - generic_type_name 80 | - ibinspectable_in_extension 81 | - identical_operands 82 | # - identifier_name 83 | - implicit_getter 84 | # - implicit_return 85 | # - implicitly_unwrapped_optional 86 | - inclusive_language 87 | # - indentation_width 88 | - inert_defer 89 | - is_disjoint 90 | - joined_default_parameter 91 | # - large_tuple 92 | - last_where 93 | - leading_whitespace 94 | - legacy_cggeometry_functions 95 | - legacy_constant 96 | - legacy_constructor 97 | - legacy_hashing 98 | - legacy_multiple 99 | - legacy_nsgeometry_functions 100 | # - legacy_objc_type 101 | - legacy_random 102 | # - let_var_whitespace 103 | # - line_length 104 | - literal_expression_end_indentation 105 | - lower_acl_than_parent 106 | - mark 107 | # - missing_docs 108 | - modifier_order 109 | - multiline_arguments 110 | # - multiline_arguments_brackets 111 | # - multiline_function_chains 112 | - multiline_literal_brackets 113 | - multiline_parameters 114 | # - multiline_parameters_brackets 115 | - multiple_closures_with_trailing_closure 116 | # - nesting 117 | - nimble_operator 118 | # - no_extension_access_modifier 119 | - no_fallthrough_only 120 | # - no_grouping_extension 121 | - no_space_in_method_call 122 | - notification_center_detachment 123 | - nslocalizedstring_key 124 | - nslocalizedstring_require_bundle 125 | - nsobject_prefer_isequal 126 | # - number_separator 127 | - object_literal 128 | - opening_brace 129 | - operator_usage_whitespace 130 | - operator_whitespace 131 | - optional_enum_case_matching 132 | # - orphaned_doc_comment 133 | - overridden_super_call 134 | - override_in_extension 135 | # - pattern_matching_keywords 136 | # - prefer_nimble 137 | # - prefer_self_in_static_references 138 | - prefer_self_type_over_type_of_self 139 | - prefer_zero_over_explicit_init 140 | # - prefixed_toplevel_constant 141 | - private_action 142 | - private_outlet 143 | - private_over_fileprivate 144 | - private_subject 145 | - private_unit_test 146 | - prohibited_interface_builder 147 | - prohibited_super_call 148 | - protocol_property_accessors_order 149 | - quick_discouraged_call 150 | - quick_discouraged_focused_test 151 | - quick_discouraged_pending_test 152 | - raw_value_for_camel_cased_codable_enum 153 | - reduce_boolean 154 | - reduce_into 155 | - redundant_discardable_let 156 | - redundant_nil_coalescing 157 | - redundant_objc_attribute 158 | - redundant_optional_initialization 159 | - redundant_set_access_control 160 | - redundant_string_enum_value 161 | - redundant_type_annotation 162 | - redundant_void_return 163 | # - required_deinit 164 | - required_enum_case 165 | - return_arrow_whitespace 166 | - return_value_from_void_function 167 | - self_binding 168 | - self_in_property_initialization 169 | - shorthand_operator 170 | # - single_test_class 171 | - sorted_first_last 172 | # - sorted_imports 173 | # - statement_position 174 | - static_operator 175 | - strict_fileprivate 176 | - strong_iboutlet 177 | # - superfluous_disable_command 178 | - switch_case_alignment 179 | - switch_case_on_newline 180 | # - syntactic_sugar 181 | # - test_case_accessibility 182 | # - todo 183 | - toggle_bool 184 | # - trailing_closure 185 | # - trailing_comma 186 | # - trailing_newline 187 | - trailing_semicolon 188 | - trailing_whitespace 189 | - type_body_length 190 | # - type_contents_order 191 | # - type_name 192 | - unavailable_condition 193 | - unavailable_function 194 | - unneeded_break_in_switch 195 | - unneeded_parentheses_in_closure_argument 196 | - unowned_variable_capture 197 | - untyped_error_in_catch 198 | - unused_capture_list 199 | - unused_closure_parameter 200 | - unused_control_flow_label 201 | - unused_enumerated 202 | - unused_optional_binding 203 | - unused_setter_value 204 | - valid_ibinspectable 205 | - vertical_parameter_alignment 206 | - vertical_parameter_alignment_on_call 207 | - vertical_whitespace 208 | # - vertical_whitespace_between_cases 209 | - vertical_whitespace_closing_braces 210 | - vertical_whitespace_opening_braces 211 | - void_function_in_ternary 212 | - void_return 213 | - weak_delegate 214 | # - xct_specific_matcher 215 | - xctfail_message 216 | - yoda_condition 217 | included: 218 | - Demo 219 | - Sources 220 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftNodeEditor.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /CAVEAT.md: -------------------------------------------------------------------------------- 1 | # CAVEAT 2 | 3 | This code is incomplete "hobby" "version zero" code and should probably not be used in production. 4 | 5 | I make no guarantees about that this codebase is bug free. I make no guarantees about the API stability of this code base. 6 | 7 | You probably should NOT depend on this Swift package in your project. If there are portions of this codebase that you find useful, you should copy them into your project and modify them to suit your needs. See LICENSE.md for more information. 8 | 9 | If you find a bug, please file an issue. If you want to fix a bug, please file an issue and then submit a pull request. 10 | -------------------------------------------------------------------------------- /Demo/Packages/SwiftNodeEditor: -------------------------------------------------------------------------------- 1 | ../.. -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 45565AFC28EABA9500356C55 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45565AFB28EABA9500356C55 /* App.swift */; }; 11 | 45565B0028EABA9700356C55 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 45565AFF28EABA9700356C55 /* Assets.xcassets */; }; 12 | 45565B0428EABA9700356C55 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 45565B0328EABA9700356C55 /* Preview Assets.xcassets */; }; 13 | 4566031D28EABFCB006EB5A9 /* SwiftNodeEditorDemo in Frameworks */ = {isa = PBXBuildFile; productRef = 4566031C28EABFCB006EB5A9 /* SwiftNodeEditorDemo */; }; 14 | 45D628DC28EDD64500296662 /* SwiftNodeEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 45D628DB28EDD64500296662 /* SwiftNodeEditor */; }; 15 | /* End PBXBuildFile section */ 16 | 17 | /* Begin PBXFileReference section */ 18 | 45565AF828EABA9500356C55 /* SwiftNodeEditorDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftNodeEditorDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 19 | 45565AFB28EABA9500356C55 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 20 | 45565AFF28EABA9700356C55 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 21 | 45565B0128EABA9700356C55 /* SwiftNodeEditorDemo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SwiftNodeEditorDemo.entitlements; sourceTree = ""; }; 22 | 45565B0328EABA9700356C55 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 23 | 45CA3A8728EE974C00430A26 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 24 | 45D628DA28EDD5EB00296662 /* SwiftNodeEditor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SwiftNodeEditor; path = ..; sourceTree = ""; }; 25 | /* End PBXFileReference section */ 26 | 27 | /* Begin PBXFrameworksBuildPhase section */ 28 | 45565AF528EABA9500356C55 /* Frameworks */ = { 29 | isa = PBXFrameworksBuildPhase; 30 | buildActionMask = 2147483647; 31 | files = ( 32 | 4566031D28EABFCB006EB5A9 /* SwiftNodeEditorDemo in Frameworks */, 33 | 45D628DC28EDD64500296662 /* SwiftNodeEditor in Frameworks */, 34 | ); 35 | runOnlyForDeploymentPostprocessing = 0; 36 | }; 37 | /* End PBXFrameworksBuildPhase section */ 38 | 39 | /* Begin PBXGroup section */ 40 | 45565AEF28EABA9500356C55 = { 41 | isa = PBXGroup; 42 | children = ( 43 | 45D628D928EDD5EB00296662 /* Packages */, 44 | 45565AFA28EABA9500356C55 /* SwiftNodeEditorDemoApp */, 45 | 45565AF928EABA9500356C55 /* Products */, 46 | 45565B0C28EABB2500356C55 /* Frameworks */, 47 | ); 48 | sourceTree = ""; 49 | }; 50 | 45565AF928EABA9500356C55 /* Products */ = { 51 | isa = PBXGroup; 52 | children = ( 53 | 45565AF828EABA9500356C55 /* SwiftNodeEditorDemoApp.app */, 54 | ); 55 | name = Products; 56 | sourceTree = ""; 57 | }; 58 | 45565AFA28EABA9500356C55 /* SwiftNodeEditorDemoApp */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | 45CA3A8728EE974C00430A26 /* Info.plist */, 62 | 45565AFB28EABA9500356C55 /* App.swift */, 63 | 45565AFF28EABA9700356C55 /* Assets.xcassets */, 64 | 45565B0128EABA9700356C55 /* SwiftNodeEditorDemo.entitlements */, 65 | 45565B0228EABA9700356C55 /* Preview Content */, 66 | ); 67 | path = SwiftNodeEditorDemoApp; 68 | sourceTree = ""; 69 | }; 70 | 45565B0228EABA9700356C55 /* Preview Content */ = { 71 | isa = PBXGroup; 72 | children = ( 73 | 45565B0328EABA9700356C55 /* Preview Assets.xcassets */, 74 | ); 75 | path = "Preview Content"; 76 | sourceTree = ""; 77 | }; 78 | 45565B0C28EABB2500356C55 /* Frameworks */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | ); 82 | name = Frameworks; 83 | sourceTree = ""; 84 | }; 85 | 45D628D928EDD5EB00296662 /* Packages */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | 45D628DA28EDD5EB00296662 /* SwiftNodeEditor */, 89 | ); 90 | name = Packages; 91 | sourceTree = ""; 92 | }; 93 | /* End PBXGroup section */ 94 | 95 | /* Begin PBXNativeTarget section */ 96 | 45565AF728EABA9500356C55 /* SwiftNodeEditorDemoApp */ = { 97 | isa = PBXNativeTarget; 98 | buildConfigurationList = 45565B0728EABA9700356C55 /* Build configuration list for PBXNativeTarget "SwiftNodeEditorDemoApp" */; 99 | buildPhases = ( 100 | 45565AF428EABA9500356C55 /* Sources */, 101 | 45565AF528EABA9500356C55 /* Frameworks */, 102 | 45565AF628EABA9500356C55 /* Resources */, 103 | ); 104 | buildRules = ( 105 | ); 106 | dependencies = ( 107 | ); 108 | name = SwiftNodeEditorDemoApp; 109 | packageProductDependencies = ( 110 | 4566031C28EABFCB006EB5A9 /* SwiftNodeEditorDemo */, 111 | 45D628DB28EDD64500296662 /* SwiftNodeEditor */, 112 | ); 113 | productName = SwiftNodeEditorDemo; 114 | productReference = 45565AF828EABA9500356C55 /* SwiftNodeEditorDemoApp.app */; 115 | productType = "com.apple.product-type.application"; 116 | }; 117 | /* End PBXNativeTarget section */ 118 | 119 | /* Begin PBXProject section */ 120 | 45565AF028EABA9500356C55 /* Project object */ = { 121 | isa = PBXProject; 122 | attributes = { 123 | BuildIndependentTargetsInParallel = 1; 124 | LastSwiftUpdateCheck = 1410; 125 | LastUpgradeCheck = 1410; 126 | TargetAttributes = { 127 | 45565AF728EABA9500356C55 = { 128 | CreatedOnToolsVersion = 14.1; 129 | }; 130 | }; 131 | }; 132 | buildConfigurationList = 45565AF328EABA9500356C55 /* Build configuration list for PBXProject "SwiftNodeEditorDemoApp" */; 133 | compatibilityVersion = "Xcode 14.0"; 134 | developmentRegion = en; 135 | hasScannedForEncodings = 0; 136 | knownRegions = ( 137 | en, 138 | Base, 139 | ); 140 | mainGroup = 45565AEF28EABA9500356C55; 141 | productRefGroup = 45565AF928EABA9500356C55 /* Products */; 142 | projectDirPath = ""; 143 | projectRoot = ""; 144 | targets = ( 145 | 45565AF728EABA9500356C55 /* SwiftNodeEditorDemoApp */, 146 | ); 147 | }; 148 | /* End PBXProject section */ 149 | 150 | /* Begin PBXResourcesBuildPhase section */ 151 | 45565AF628EABA9500356C55 /* Resources */ = { 152 | isa = PBXResourcesBuildPhase; 153 | buildActionMask = 2147483647; 154 | files = ( 155 | 45565B0428EABA9700356C55 /* Preview Assets.xcassets in Resources */, 156 | 45565B0028EABA9700356C55 /* Assets.xcassets in Resources */, 157 | ); 158 | runOnlyForDeploymentPostprocessing = 0; 159 | }; 160 | /* End PBXResourcesBuildPhase section */ 161 | 162 | /* Begin PBXSourcesBuildPhase section */ 163 | 45565AF428EABA9500356C55 /* Sources */ = { 164 | isa = PBXSourcesBuildPhase; 165 | buildActionMask = 2147483647; 166 | files = ( 167 | 45565AFC28EABA9500356C55 /* App.swift in Sources */, 168 | ); 169 | runOnlyForDeploymentPostprocessing = 0; 170 | }; 171 | /* End PBXSourcesBuildPhase section */ 172 | 173 | /* Begin XCBuildConfiguration section */ 174 | 45565B0528EABA9700356C55 /* Debug */ = { 175 | isa = XCBuildConfiguration; 176 | buildSettings = { 177 | ALWAYS_SEARCH_USER_PATHS = NO; 178 | CLANG_ANALYZER_NONNULL = YES; 179 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 180 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 181 | CLANG_ENABLE_MODULES = YES; 182 | CLANG_ENABLE_OBJC_ARC = YES; 183 | CLANG_ENABLE_OBJC_WEAK = YES; 184 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 185 | CLANG_WARN_BOOL_CONVERSION = YES; 186 | CLANG_WARN_COMMA = YES; 187 | CLANG_WARN_CONSTANT_CONVERSION = YES; 188 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 189 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 190 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 191 | CLANG_WARN_EMPTY_BODY = YES; 192 | CLANG_WARN_ENUM_CONVERSION = YES; 193 | CLANG_WARN_INFINITE_RECURSION = YES; 194 | CLANG_WARN_INT_CONVERSION = YES; 195 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 196 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 197 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 198 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 199 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 200 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 201 | CLANG_WARN_STRICT_PROTOTYPES = YES; 202 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 203 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 204 | CLANG_WARN_UNREACHABLE_CODE = YES; 205 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 206 | COPY_PHASE_STRIP = NO; 207 | DEBUG_INFORMATION_FORMAT = dwarf; 208 | ENABLE_STRICT_OBJC_MSGSEND = YES; 209 | ENABLE_TESTABILITY = YES; 210 | GCC_C_LANGUAGE_STANDARD = gnu11; 211 | GCC_DYNAMIC_NO_PIC = NO; 212 | GCC_NO_COMMON_BLOCKS = YES; 213 | GCC_OPTIMIZATION_LEVEL = 0; 214 | GCC_PREPROCESSOR_DEFINITIONS = ( 215 | "DEBUG=1", 216 | "$(inherited)", 217 | ); 218 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 219 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 220 | GCC_WARN_UNDECLARED_SELECTOR = YES; 221 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 222 | GCC_WARN_UNUSED_FUNCTION = YES; 223 | GCC_WARN_UNUSED_VARIABLE = YES; 224 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 225 | MTL_FAST_MATH = YES; 226 | ONLY_ACTIVE_ARCH = YES; 227 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 228 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 229 | }; 230 | name = Debug; 231 | }; 232 | 45565B0628EABA9700356C55 /* Release */ = { 233 | isa = XCBuildConfiguration; 234 | buildSettings = { 235 | ALWAYS_SEARCH_USER_PATHS = NO; 236 | CLANG_ANALYZER_NONNULL = YES; 237 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 238 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 239 | CLANG_ENABLE_MODULES = YES; 240 | CLANG_ENABLE_OBJC_ARC = YES; 241 | CLANG_ENABLE_OBJC_WEAK = YES; 242 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 243 | CLANG_WARN_BOOL_CONVERSION = YES; 244 | CLANG_WARN_COMMA = YES; 245 | CLANG_WARN_CONSTANT_CONVERSION = YES; 246 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 247 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 248 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 249 | CLANG_WARN_EMPTY_BODY = YES; 250 | CLANG_WARN_ENUM_CONVERSION = YES; 251 | CLANG_WARN_INFINITE_RECURSION = YES; 252 | CLANG_WARN_INT_CONVERSION = YES; 253 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 254 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 255 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 256 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 257 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 258 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 259 | CLANG_WARN_STRICT_PROTOTYPES = YES; 260 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 261 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 262 | CLANG_WARN_UNREACHABLE_CODE = YES; 263 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 264 | COPY_PHASE_STRIP = NO; 265 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 266 | ENABLE_NS_ASSERTIONS = NO; 267 | ENABLE_STRICT_OBJC_MSGSEND = YES; 268 | GCC_C_LANGUAGE_STANDARD = gnu11; 269 | GCC_NO_COMMON_BLOCKS = YES; 270 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 271 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 272 | GCC_WARN_UNDECLARED_SELECTOR = YES; 273 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 274 | GCC_WARN_UNUSED_FUNCTION = YES; 275 | GCC_WARN_UNUSED_VARIABLE = YES; 276 | MTL_ENABLE_DEBUG_INFO = NO; 277 | MTL_FAST_MATH = YES; 278 | SWIFT_COMPILATION_MODE = wholemodule; 279 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 280 | }; 281 | name = Release; 282 | }; 283 | 45565B0828EABA9700356C55 /* Debug */ = { 284 | isa = XCBuildConfiguration; 285 | buildSettings = { 286 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 287 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 288 | CODE_SIGN_ENTITLEMENTS = SwiftNodeEditorDemoApp/SwiftNodeEditorDemo.entitlements; 289 | CODE_SIGN_STYLE = Automatic; 290 | CURRENT_PROJECT_VERSION = 1; 291 | DEVELOPMENT_ASSET_PATHS = "\"SwiftNodeEditorDemoApp/Preview Content\""; 292 | DEVELOPMENT_TEAM = 6E23EP94PG; 293 | ENABLE_HARDENED_RUNTIME = YES; 294 | ENABLE_PREVIEWS = YES; 295 | GENERATE_INFOPLIST_FILE = YES; 296 | INFOPLIST_FILE = SwiftNodeEditorDemoApp/Info.plist; 297 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 298 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 299 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 300 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 301 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 302 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 303 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 304 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 305 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 306 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 307 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 308 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 309 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 310 | MACOSX_DEPLOYMENT_TARGET = 13.0; 311 | MARKETING_VERSION = 1.0; 312 | PRODUCT_BUNDLE_IDENTIFIER = io.schwa.SwiftNodeEditorDemoApp; 313 | PRODUCT_NAME = "$(TARGET_NAME)"; 314 | SDKROOT = auto; 315 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 316 | SWIFT_EMIT_LOC_STRINGS = YES; 317 | SWIFT_VERSION = 5.0; 318 | TARGETED_DEVICE_FAMILY = "1,2"; 319 | }; 320 | name = Debug; 321 | }; 322 | 45565B0928EABA9700356C55 /* Release */ = { 323 | isa = XCBuildConfiguration; 324 | buildSettings = { 325 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 326 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 327 | CODE_SIGN_ENTITLEMENTS = SwiftNodeEditorDemoApp/SwiftNodeEditorDemo.entitlements; 328 | CODE_SIGN_STYLE = Automatic; 329 | CURRENT_PROJECT_VERSION = 1; 330 | DEVELOPMENT_ASSET_PATHS = "\"SwiftNodeEditorDemoApp/Preview Content\""; 331 | DEVELOPMENT_TEAM = 6E23EP94PG; 332 | ENABLE_HARDENED_RUNTIME = YES; 333 | ENABLE_PREVIEWS = YES; 334 | GENERATE_INFOPLIST_FILE = YES; 335 | INFOPLIST_FILE = SwiftNodeEditorDemoApp/Info.plist; 336 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 337 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 338 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 339 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 340 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 341 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 342 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 343 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 344 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 345 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 346 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 347 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 348 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 349 | MACOSX_DEPLOYMENT_TARGET = 13.0; 350 | MARKETING_VERSION = 1.0; 351 | PRODUCT_BUNDLE_IDENTIFIER = io.schwa.SwiftNodeEditorDemoApp; 352 | PRODUCT_NAME = "$(TARGET_NAME)"; 353 | SDKROOT = auto; 354 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 355 | SWIFT_EMIT_LOC_STRINGS = YES; 356 | SWIFT_VERSION = 5.0; 357 | TARGETED_DEVICE_FAMILY = "1,2"; 358 | }; 359 | name = Release; 360 | }; 361 | /* End XCBuildConfiguration section */ 362 | 363 | /* Begin XCConfigurationList section */ 364 | 45565AF328EABA9500356C55 /* Build configuration list for PBXProject "SwiftNodeEditorDemoApp" */ = { 365 | isa = XCConfigurationList; 366 | buildConfigurations = ( 367 | 45565B0528EABA9700356C55 /* Debug */, 368 | 45565B0628EABA9700356C55 /* Release */, 369 | ); 370 | defaultConfigurationIsVisible = 0; 371 | defaultConfigurationName = Release; 372 | }; 373 | 45565B0728EABA9700356C55 /* Build configuration list for PBXNativeTarget "SwiftNodeEditorDemoApp" */ = { 374 | isa = XCConfigurationList; 375 | buildConfigurations = ( 376 | 45565B0828EABA9700356C55 /* Debug */, 377 | 45565B0928EABA9700356C55 /* Release */, 378 | ); 379 | defaultConfigurationIsVisible = 0; 380 | defaultConfigurationName = Release; 381 | }; 382 | /* End XCConfigurationList section */ 383 | 384 | /* Begin XCSwiftPackageProductDependency section */ 385 | 4566031C28EABFCB006EB5A9 /* SwiftNodeEditorDemo */ = { 386 | isa = XCSwiftPackageProductDependency; 387 | productName = SwiftNodeEditorDemo; 388 | }; 389 | 45D628DB28EDD64500296662 /* SwiftNodeEditor */ = { 390 | isa = XCSwiftPackageProductDependency; 391 | productName = SwiftNodeEditor; 392 | }; 393 | /* End XCSwiftPackageProductDependency section */ 394 | }; 395 | rootObject = 45565AF028EABA9500356C55 /* Project object */; 396 | } 397 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "coregraphicsgeometrysupport", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/schwa/CoreGraphicsGeometrySupport", 7 | "state" : { 8 | "revision" : "8032fa40acffc8791aa85f0ae347ae4feb197dd2", 9 | "version" : "0.0.2" 10 | } 11 | }, 12 | { 13 | "identity" : "everything", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/schwa/Everything", 16 | "state" : { 17 | "revision" : "0a562a585ea02b802a01d5a7583cde5a31bc4f2d", 18 | "version" : "0.1.1" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-algorithms", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-algorithms", 25 | "state" : { 26 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 27 | "version" : "1.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections.git", 34 | "state" : { 35 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 36 | "version" : "1.0.4" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-numerics", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-numerics", 43 | "state" : { 44 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 45 | "version" : "1.0.2" 46 | } 47 | } 48 | ], 49 | "version" : 2 50 | } 51 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp.xcodeproj/xcshareddata/xcschemes/SwiftNodeEditorDemoApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftNodeEditorDemo 2 | import SwiftUI 3 | 4 | @main 5 | struct SwiftNodeEditorDemoApp: App { 6 | var body: some Scene { 7 | DocumentGroup(newDocument: GraphDocument()) { file in 8 | NodeGraphEditorDemoView(document: file.$document) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp/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 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | LSHandlerRank 11 | Default 12 | LSItemContentTypes 13 | 14 | io.schwa.nodegraph 15 | 16 | 17 | 18 | UTExportedTypeDeclarations 19 | 20 | 21 | UTTypeConformsTo 22 | 23 | public.text 24 | 25 | UTTypeDescription 26 | nodegraph 27 | UTTypeIcons 28 | 29 | UTTypeIdentifier 30 | io.schwa.nodegraph 31 | UTTypeTagSpecification 32 | 33 | public.filename-extension 34 | 35 | nodegraph 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/SwiftNodeEditorDemoApp/SwiftNodeEditorDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Documentation/Screen Recording 1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schwa/SwiftNodeEditor/2a4e6c27f558430182eedc8721df7f08ed62e80e/Documentation/Screen Recording 1.gif -------------------------------------------------------------------------------- /Documentation/Screenshot 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schwa/SwiftNodeEditor/2a4e6c27f558430182eedc8721df7f08ed62e80e/Documentation/Screenshot 1.png -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Jonathan Wight 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "coregraphicsgeometrysupport", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/schwa/CoreGraphicsGeometrySupport", 7 | "state" : { 8 | "revision" : "8032fa40acffc8791aa85f0ae347ae4feb197dd2", 9 | "version" : "0.0.2" 10 | } 11 | }, 12 | { 13 | "identity" : "everything", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/schwa/Everything", 16 | "state" : { 17 | "revision" : "0a562a585ea02b802a01d5a7583cde5a31bc4f2d", 18 | "version" : "0.1.1" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-algorithms", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-algorithms", 25 | "state" : { 26 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 27 | "version" : "1.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections.git", 34 | "state" : { 35 | "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", 36 | "version" : "1.0.3" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-numerics", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-numerics", 43 | "state" : { 44 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 45 | "version" : "1.0.2" 46 | } 47 | } 48 | ], 49 | "version" : 2 50 | } 51 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftNodeEditor", 8 | platforms: [ 9 | .iOS("16.0"), 10 | .macOS("13.0"), 11 | .macCatalyst("16.0"), 12 | ], 13 | products: [ 14 | .library( 15 | name: "SwiftNodeEditor", 16 | targets: ["SwiftNodeEditor"] 17 | ), 18 | .library( 19 | name: "SwiftNodeEditorDemo", 20 | targets: ["SwiftNodeEditorDemo"] 21 | ), 22 | ], 23 | dependencies: [ 24 | .package(url: "https://github.com/schwa/Everything", .upToNextMajor(from: "0.1.0")), 25 | .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.3")), 26 | ], 27 | targets: [ 28 | .target( 29 | name: "SwiftNodeEditor", 30 | dependencies: [ 31 | "Everything", 32 | .product(name: "Collections", package: "swift-collections"), 33 | ] 34 | ), 35 | .target( 36 | name: "SwiftNodeEditorDemo", 37 | dependencies: [ 38 | "SwiftNodeEditor", 39 | .product(name: "Collections", package: "swift-collections"), 40 | ] 41 | ), 42 | .testTarget( 43 | name: "SwiftNodeEditorTests", 44 | dependencies: ["SwiftNodeEditor"] 45 | ), 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /Playgrounds/MyPlayground.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | //: A Cocoa based Playground to present user interface 2 | 3 | import AppKit 4 | import PlaygroundSupport 5 | 6 | let nibFile = NSNib.Name("MyView") 7 | var topLevelObjects: NSArray? 8 | 9 | Bundle.main.loadNibNamed(nibFile, owner: nil, topLevelObjects: &topLevelObjects) 10 | let views = (topLevelObjects as! [Any]).filter { $0 is NSView } 11 | 12 | // Present the view in Playground 13 | PlaygroundPage.current.liveView = views[0] as! NSView 14 | -------------------------------------------------------------------------------- /Playgrounds/MyPlayground.playground/Resources/MyView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Playgrounds/MyPlayground.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftNodeEditor 2 | 3 | A package of SwiftUI code for making node editors 4 | 5 | ## Screenshot 6 | 7 | ![Screen Recording 1](Documentation/Screen%20Recording%201.gif) 8 | 9 | ## Usage 10 | 11 | 1. `import SwiftNodeEditor` 12 | 2. Conform your model types to `NodeProtocol` (nodes contain sockets), `SocketProtocol` (sockets are connected with wires) and `WireProtocol`. 13 | 3. Implement a `PresentationProtocol` to control how your model types are presented and can be interacted with. 14 | 4. Embed a `NodeGraphEditorView` in your SwiftUI view hierarchy and provide it with your model types and presentation protocol. 15 | 16 | See SwiftNodeEditorDemo for a complex example showing multiple presentation protocols and several ways of interacting with your objects. 17 | 18 | ## License 19 | 20 | See [LICENSE.md](LICENSE.md). 21 | 22 | ## Caveats 23 | 24 | This project is (currently) undergoing active development and its API surface area is not yet stable. 25 | 26 | See also [CAVEAT.md](CAVEAT.md). 27 | 28 | ## TODO 29 | 30 | ### High Priority 31 | 32 | - [ ] Grep code for TODOs & fix 'em. 33 | - [ ] Remove dependency on Everything (may need to put Everything's CoreGraphics in a new package?) 34 | - [ ] That GeometryReader makes it hard for wire/pin presentation to work without hard-coding size. (Would a layout help?) 35 | - [ ] Documentation. 36 | - [ ] Fix project structure - the app and demo package should be merged. 37 | - [ ] Simple demo. 38 | - [X] Add presentation for pins. 39 | - [X] Cannot debug in Xcode 14.0 beta 3 (TODO: file a feedback report). 40 | - [X] Add presentation for sockets. 41 | - [X] Add presentation for wires. 42 | 43 | ### Nice to Have 44 | 45 | - [ ] The differences in the 'content(for:)' api are weird 46 | - [ ] It's silly that both pins and sockets need to register the same drag gesture - make one gesture and raise it above. 47 | - [ ] Interface protocols only need to be Identifiable not Hashable also. 48 | - [ ] Socket sizes are hard-coded. 49 | - [ ] Pins are not the same colours as their wires. 50 | - [ ] Add presentation for pins. 51 | - [ ] Investigating use NodeStyle/WireStyle etc inside Presentation 52 | - [ ] Turn README's TODO list into GitHub issues. 53 | - [ ] Add tools to layout nodes. 54 | - [ ] Add more streamlined HI for adding nodes. 55 | - [ ] Add better z-layer behaviour. 56 | - [ ] Unit tests for model-layer. 57 | - [ ] Labels on wires. 58 | - [ ] Marquee-based selection. 59 | - [ ] Keyboard shortcuts. 60 | - [ ] The Demo app needs a List representation. 61 | - [ ] Selector mechanism for sockets and wires (can I connect this wire to this socket?) 62 | - [ ] Many-to-many sockets 63 | - [ ] Use OrderedSet (from swift-collections) in correct places. 64 | - [ ] Use more from macOS13/iOS16 in this (layouts, backgroundStyle, etc?) 65 | - [X] Make demo a document-based app with JSON serialization. 66 | - [X] Make demo labels editable. 67 | - [X] Get rid of weird underscore naming with generics. 68 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditor/Interface.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import SwiftUI 3 | 4 | public protocol NodeProtocol: Identifiable { 5 | associatedtype Socket: SocketProtocol 6 | var position: CGPoint { get set } 7 | // TODO: Can add same socket twice. Need an OrderedSet 8 | var sockets: [Socket] { get set } 9 | } 10 | 11 | // TODO: You cannot go from Sockets to other types. This could be an issue for implementors - but there's no reason implementors can't provide this themselves. 12 | public protocol WireProtocol: Identifiable, Equatable { 13 | associatedtype Socket: SocketProtocol 14 | var sourceSocket: Socket { get } 15 | var destinationSocket: Socket { get } 16 | 17 | init(sourceSocket: Socket, destinationSocket: Socket) 18 | } 19 | 20 | public protocol SocketProtocol: Identifiable, Hashable { 21 | } 22 | 23 | // MARK: - 24 | 25 | public protocol PresentationProtocol { 26 | associatedtype Node: NodeProtocol where Node.Socket == Socket 27 | associatedtype Wire: WireProtocol where Wire.Socket == Socket 28 | associatedtype Socket: SocketProtocol 29 | 30 | associatedtype NodeContent: View 31 | associatedtype WireContent: View 32 | associatedtype SocketContent: View 33 | associatedtype PinContent: View 34 | 35 | func content(for node: Binding, configuration: NodeConfiguration) -> NodeContent 36 | func content(for wire: Binding, configuration: WireConfiguration) -> WireContent 37 | func content(for socket: Socket) -> SocketContent 38 | func content(forPin socket: Socket) -> PinContent 39 | } 40 | 41 | public struct NodeConfiguration { 42 | @Binding 43 | public var selected: Bool 44 | } 45 | 46 | public struct WireConfiguration { 47 | public let active: Bool 48 | public let start: CGPoint 49 | public let end: CGPoint 50 | } 51 | 52 | /* 53 | TODO: Use Style style protocols instead of content(for:) 54 | 55 | public protocol ButtonStyle { 56 | associatedtype Body : View 57 | @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body 58 | typealias Configuration = ButtonStyleConfiguration 59 | } 60 | 61 | public struct ButtonStyleConfiguration { 62 | /// A type-erased label of a button. 63 | public struct Label : View { 64 | public typealias Body = Never 65 | } 66 | 67 | public let role: ButtonRole? 68 | public let label: ButtonStyleConfiguration.Label 69 | public let isPressed: Bool 70 | } 71 | */ 72 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditor/Model.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | internal class Model: ObservableObject where Presentation: PresentationProtocol { 5 | typealias Node = Presentation.Node 6 | typealias Wire = Presentation.Wire 7 | typealias Socket = Presentation.Socket 8 | 9 | @Binding 10 | var nodes: [Node] 11 | 12 | @Binding 13 | var wires: [Wire] 14 | 15 | @Binding 16 | var selection: Set 17 | 18 | let presentation: Presentation 19 | 20 | init(nodes: Binding<[Node]>, wires: Binding<[Wire]>, selection: Binding>, presentation: Presentation) { 21 | _nodes = nodes 22 | _wires = wires 23 | _selection = selection 24 | self.presentation = presentation 25 | } 26 | } 27 | 28 | // MARK: PreferenceKeys & EnvironmentKeys (& relevant modifiers) 29 | 30 | // MARK: SocketGeometriesPreferenceKey 31 | 32 | internal struct SocketGeometriesPreferenceKey: PreferenceKey where Socket: SocketProtocol { 33 | typealias Value = [Socket: CGRect] 34 | 35 | static var defaultValue: Value { 36 | [:] 37 | } 38 | 39 | static func reduce(value: inout Value, nextValue: () -> Value) { 40 | value.merge(nextValue()) { _, rhs in 41 | rhs 42 | } 43 | } 44 | } 45 | 46 | // MARK: onActiveWireDragEnded 47 | 48 | internal struct OnActiveWireDragEndedKey: EnvironmentKey { 49 | typealias Value = (() -> Void)? 50 | static var defaultValue: Value = nil 51 | } 52 | 53 | extension EnvironmentValues { 54 | var onActiveWireDragEnded: OnActiveWireDragEndedKey.Value { 55 | get { 56 | self[OnActiveWireDragEndedKey.self] 57 | } 58 | set { 59 | self[OnActiveWireDragEndedKey.self] = newValue 60 | } 61 | } 62 | } 63 | 64 | internal struct OnActiveWireDragEndedModifier: ViewModifier { 65 | let value: OnActiveWireDragEndedKey.Value 66 | 67 | func body(content: Content) -> some View { 68 | content.environment(\.onActiveWireDragEnded, value) 69 | } 70 | } 71 | 72 | extension View { 73 | func onActiveWireDragEnded(value: OnActiveWireDragEndedKey.Value) -> some View { 74 | modifier(OnActiveWireDragEndedModifier(value: value)) 75 | } 76 | } 77 | 78 | // MARK: ActiveWire 79 | 80 | struct ActiveWire: Equatable where Presentation: PresentationProtocol { 81 | typealias Node = Presentation.Node 82 | typealias Wire = Presentation.Wire 83 | typealias Socket = Presentation.Socket 84 | 85 | let startLocation: CGPoint 86 | let endLocation: CGPoint 87 | let startSocket: Socket 88 | let existingWire: Wire? 89 | 90 | init(startLocation: CGPoint, endLocation: CGPoint, startSocket: Socket, existingWire: Wire?) { 91 | self.startLocation = startLocation 92 | self.endLocation = endLocation 93 | self.startSocket = startSocket 94 | self.existingWire = existingWire 95 | } 96 | } 97 | 98 | // MARK: ActiveWirePreferenceKey 99 | 100 | struct ActiveWirePreferenceKey: PreferenceKey where Presentation: PresentationProtocol { 101 | typealias Node = Presentation.Node 102 | typealias Wire = Presentation.Wire 103 | typealias Socket = Presentation.Socket 104 | 105 | static var defaultValue: ActiveWire? { 106 | nil 107 | } 108 | 109 | static func reduce(value: inout ActiveWire?, nextValue: () -> ActiveWire?) { 110 | value = nextValue() ?? value 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditor/NodeGraphEditorView.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable file_length 2 | 3 | import Everything 4 | import SwiftUI 5 | 6 | public struct NodeGraphEditorView: View where Presentation: PresentationProtocol { 7 | // TODO: This is NOT a StateObject - it should be. 8 | let model: Model 9 | 10 | public init(nodes: Binding<[Presentation.Node]>, wires: Binding<[Presentation.Wire]>, selection: Binding>, presentation: Presentation) { 11 | model = Model(nodes: nodes, wires: wires, selection: selection, presentation: presentation) 12 | } 13 | 14 | public var body: some View { 15 | NodeGraphEditorView_() 16 | .environmentObject(model) 17 | } 18 | 19 | struct NodeGraphEditorView_: View { 20 | typealias Node = Presentation.Node 21 | typealias Wire = Presentation.Wire 22 | typealias Socket = Presentation.Socket 23 | 24 | @EnvironmentObject 25 | var model: Model 26 | 27 | @State 28 | var activeWire: ActiveWire? 29 | 30 | @State 31 | var socketGeometries: [Socket: CGRect]? 32 | 33 | @Environment(\.backgroundStyle) 34 | var backgroundStyle 35 | 36 | var body: some View { 37 | ZStack { 38 | Rectangle().fill(backgroundStyle ?? AnyShapeStyle(.white)) 39 | .onTapGesture { 40 | model.selection = [] 41 | } 42 | NodesView() 43 | socketGeometries.map { socketGeometries in 44 | WiresView(wires: model.$wires, socketGeometries: socketGeometries) 45 | } 46 | socketGeometries.map { socketGeometries in 47 | activeWire.map { ActiveWireView(activeWire: $0, socketGeometries: socketGeometries) } 48 | } 49 | } 50 | .coordinateSpace(name: CoordinateSpace.canvasName) 51 | .onPreferenceChange(ActiveWirePreferenceKey.self) { activeWire in 52 | self.activeWire = activeWire 53 | } 54 | .onPreferenceChange(SocketGeometriesPreferenceKey.self) { socketGeometries in 55 | self.socketGeometries = socketGeometries 56 | } 57 | .onActiveWireDragEnded { 58 | guard let activeWire else { 59 | fatalError("No active wire") 60 | } 61 | guard let socketGeometries else { 62 | fatalError("No socket geometries") 63 | } 64 | for (socket, frame) in socketGeometries { 65 | if frame.contains(activeWire.endLocation) { 66 | model.wires.append(Wire(sourceSocket: activeWire.startSocket, destinationSocket: socket)) 67 | return 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | // MARK: Node Views 76 | 77 | internal struct NodesView: View where Presentation: PresentationProtocol { 78 | typealias Node = Presentation.Node 79 | typealias Wire = Presentation.Wire 80 | typealias Socket = Presentation.Socket 81 | 82 | @EnvironmentObject 83 | var model: Model 84 | 85 | var body: some View { 86 | ForEach(model.nodes) { node in 87 | let index = model.nodes.firstIndex(where: { node.id == $0.id })! 88 | let nodeBinding = Binding { model.nodes[index] } set: { model.nodes[index] = $0 } 89 | let selectedBinding = Binding { 90 | model.selection.contains(where: { $0 == node.id }) 91 | } 92 | set: { 93 | if $0 { 94 | model.selection = [node.id] 95 | } 96 | else { 97 | model.selection.remove(node.id) 98 | } 99 | } 100 | NodeInteractionView(node: nodeBinding, selected: selectedBinding) 101 | .position(x: node.position.x, y: node.position.y) 102 | } 103 | } 104 | } 105 | 106 | internal struct NodeInteractionView: View where Presentation: PresentationProtocol { 107 | typealias Node = Presentation.Node 108 | typealias Wire = Presentation.Wire 109 | typealias Socket = Presentation.Socket 110 | 111 | @EnvironmentObject 112 | var model: Model 113 | 114 | @Binding 115 | var node: Node 116 | 117 | @Binding 118 | var selected: Bool 119 | 120 | @State 121 | var dragging = false 122 | 123 | @State 124 | var dragOffset: CGPoint = .zero 125 | 126 | var body: some View { 127 | model.presentation 128 | .content(for: _node, configuration: NodeConfiguration(selected: _selected)) 129 | .gesture(dragGesture()) 130 | .onTapGesture { 131 | selected.toggle() 132 | } 133 | } 134 | 135 | func dragGesture() -> some Gesture { 136 | DragGesture(coordinateSpace: .canvas) 137 | .onChanged { value in 138 | if dragging == false { 139 | dragOffset = value.location - node.position 140 | } 141 | node.position = value.location - dragOffset 142 | dragging = true 143 | } 144 | .onEnded { _ in 145 | dragging = false 146 | } 147 | } 148 | } 149 | 150 | // MARK: Wire Views 151 | 152 | internal struct WiresView: View where Presentation: PresentationProtocol { 153 | typealias Node = Presentation.Node 154 | typealias Wire = Presentation.Wire 155 | typealias Socket = Presentation.Socket 156 | 157 | @Binding 158 | var wires: [Wire] 159 | 160 | let socketGeometries: [Socket: CGRect] 161 | 162 | var body: some View { 163 | ForEach(wires) { wire in 164 | if let sourceRect = socketGeometries[wire.sourceSocket], let destinationRect = socketGeometries[wire.destinationSocket] { 165 | let index = wires.firstIndex(where: { wire.id == $0.id })! 166 | let binding = Binding(get: { wires[index] }, set: { wires[index] = $0 }) 167 | WireView(wire: binding, start: sourceRect.midXMidY, end: destinationRect.midXMidY) 168 | } 169 | } 170 | } 171 | } 172 | 173 | internal struct WireView: View where Presentation: PresentationProtocol { 174 | typealias Node = Presentation.Node 175 | typealias Wire = Presentation.Wire 176 | typealias Socket = Presentation.Socket 177 | 178 | @EnvironmentObject 179 | var model: Model 180 | 181 | @Binding 182 | var wire: Wire 183 | 184 | let start: CGPoint 185 | let end: CGPoint 186 | 187 | @State 188 | var activeWire: ActiveWire? 189 | 190 | var body: some View { 191 | let configuration = WireConfiguration(active: activeWire?.existingWire == wire, start: start, end: end) 192 | ChromeView(wire: _wire, configuration: configuration) 193 | .onPreferenceChange(ActiveWirePreferenceKey.self) { activeWire in 194 | self.activeWire = activeWire 195 | } 196 | .contextMenu { 197 | Button("Delete") { 198 | model.wires.removeAll(where: { wire.id == $0.id }) 199 | } 200 | } 201 | } 202 | 203 | struct ChromeView: View { 204 | @Binding 205 | var wire: Wire 206 | 207 | @EnvironmentObject 208 | var model: Model 209 | 210 | let configuration: WireConfiguration 211 | 212 | var body: some View { 213 | model.presentation.content(for: _wire, configuration: configuration) 214 | .overlay(PinView(wire: _wire, socket: wire.sourceSocket, location: configuration.start)) 215 | .overlay(PinView(wire: _wire, socket: wire.destinationSocket, location: configuration.end)) 216 | .opacity(configuration.active ? 0.33 : 1) 217 | } 218 | } 219 | } 220 | 221 | internal struct ActiveWireView: View where Presentation: PresentationProtocol { 222 | typealias Node = Presentation.Node 223 | typealias Wire = Presentation.Wire 224 | typealias Socket = Presentation.Socket 225 | 226 | let start: CGPoint 227 | let end: CGPoint 228 | let color: Color 229 | 230 | init(activeWire: ActiveWire, socketGeometries: [ActiveWireView.Socket: CGRect]) { 231 | var color: Color { 232 | for (_, frame) in socketGeometries { 233 | if frame.contains(activeWire.endLocation) { 234 | return Color.placeholder1 235 | } 236 | } 237 | return Color.placeholderBlack 238 | } 239 | self.color = color 240 | 241 | var destinationSocket: Socket? { 242 | for (socket, frame) in socketGeometries { 243 | if frame.contains(activeWire.endLocation) { 244 | return socket 245 | } 246 | } 247 | return nil 248 | } 249 | start = socketGeometries[activeWire.startSocket]!.midXMidY 250 | end = destinationSocket.map { socketGeometries[$0]! }.map(\.midXMidY) ?? activeWire.endLocation 251 | } 252 | 253 | var body: some View { 254 | AnimatedWire(start: start, end: end, foreground: color) 255 | } 256 | } 257 | 258 | // MARK: Socket Views 259 | 260 | public struct SocketView: View where Presentation: PresentationProtocol { 261 | public typealias Node = Presentation.Node 262 | public typealias Socket = Presentation.Socket 263 | 264 | @Binding 265 | var node: Node 266 | 267 | @EnvironmentObject 268 | var model: Model 269 | 270 | let socket: Socket 271 | 272 | public init(node: Binding, socket: Socket) { 273 | self.socket = socket 274 | _node = node 275 | } 276 | 277 | public var body: some View { 278 | WireDragSource(presentationType: Presentation.self, socket: socket, existingWire: nil) { 279 | GeometryReader { geometry in 280 | model.presentation.content(for: socket) 281 | .preference(key: SocketGeometriesPreferenceKey.self, value: [socket: geometry.frame(in: .canvas)]) 282 | } 283 | .frame(width: 16, height: 16) 284 | } 285 | } 286 | } 287 | 288 | // MARK: Pin Views 289 | 290 | internal struct PinView: View where Presentation: PresentationProtocol { 291 | typealias Node = Presentation.Node 292 | typealias Wire = Presentation.Wire 293 | typealias Socket = Presentation.Socket 294 | 295 | @EnvironmentObject 296 | var model: Model 297 | 298 | @Binding 299 | var wire: Wire 300 | 301 | let socket: Socket 302 | let location: CGPoint 303 | 304 | var body: some View { 305 | WireDragSource(presentationType: Presentation.self, socket: socket, existingWire: wire) { 306 | model.presentation.content(forPin: socket) 307 | .offset(location) 308 | } 309 | } 310 | } 311 | 312 | // MARK: Misc views 313 | 314 | internal struct WireDragSource: View where Presentation: PresentationProtocol, Content: View { 315 | typealias Node = Presentation.Node 316 | typealias Wire = Presentation.Wire 317 | typealias Socket = Presentation.Socket 318 | 319 | let socket: Socket 320 | let existingWire: Wire? 321 | let content: () -> Content 322 | 323 | @EnvironmentObject 324 | var model: Model 325 | 326 | @Environment(\.onActiveWireDragEnded) 327 | var onActiveWireDragEnded 328 | 329 | @State 330 | var activeWire: ActiveWire? 331 | 332 | @State 333 | var dragging = false 334 | 335 | // TODO: presentationType is a hack to allow us to create WireDragSources without having to specify both types. 336 | init(presentationType: Presentation.Type, socket: Socket, existingWire: Wire?, content: @escaping () -> Content) { 337 | self.socket = socket 338 | self.existingWire = existingWire 339 | self.content = content 340 | } 341 | 342 | var body: some View { 343 | content() 344 | .preference(key: ActiveWirePreferenceKey.self, value: activeWire) 345 | .gesture(dragGesture()) 346 | } 347 | 348 | func dragGesture() -> some Gesture { 349 | DragGesture(coordinateSpace: .named("canvas")) 350 | .onChanged { value in 351 | dragging = true 352 | 353 | activeWire = ActiveWire(startLocation: value.startLocation, endLocation: value.location, startSocket: socket, existingWire: existingWire) 354 | } 355 | .onEnded { _ in 356 | onActiveWireDragEnded?() 357 | dragging = false 358 | activeWire = nil 359 | if let existingWire = existingWire { 360 | model.wires.removeAll { $0.id == existingWire.id } 361 | } 362 | } 363 | } 364 | } 365 | 366 | private extension CoordinateSpace { 367 | static let canvasName = "canvas" 368 | static let canvas = CoordinateSpace.named(canvasName) 369 | } 370 | 371 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditor/OrderedIDSet.swift: -------------------------------------------------------------------------------- 1 | import Collections 2 | import Foundation 3 | 4 | public struct OrderedIDSet where Element: Identifiable { 5 | fileprivate typealias Storage = OrderedDictionary 6 | fileprivate var storage = Storage() 7 | } 8 | 9 | // MARK: - 10 | 11 | extension OrderedIDSet: Sequence { 12 | public typealias Iterator = AnyIterator 13 | 14 | public func makeIterator() -> Iterator { 15 | AnyIterator(storage.values.makeIterator()) 16 | } 17 | } 18 | 19 | extension OrderedIDSet: Collection { 20 | public typealias Index = Int 21 | 22 | public var count: Int { 23 | storage.count 24 | } 25 | 26 | public var isEmpty: Bool { 27 | storage.isEmpty 28 | } 29 | 30 | public var startIndex: Index { 31 | storage.values.startIndex 32 | } 33 | 34 | public var endIndex: Index { 35 | storage.values.endIndex 36 | } 37 | 38 | public subscript(position: Index) -> Element { 39 | get { 40 | storage.values[position] 41 | } 42 | set { 43 | storage.values[position] = newValue 44 | } 45 | } 46 | 47 | public func index(after i: Index) -> Index { 48 | storage.values.index(after: i) 49 | } 50 | } 51 | 52 | extension OrderedIDSet: RandomAccessCollection { 53 | } 54 | 55 | // MARK: - 56 | 57 | public extension OrderedIDSet { 58 | init(_ elements: C) where C: Collection, C.Element == Element { 59 | storage = .init(uniqueKeysWithValues: elements.map { ($0.id, $0) }) 60 | } 61 | 62 | mutating func insert(_ element: Element) { 63 | storage[element.id] = element 64 | } 65 | 66 | mutating func remove(_ element: Element) { 67 | storage[element.id] = nil 68 | } 69 | 70 | subscript(id id: Element.ID) -> Element? { 71 | get { 72 | storage[id] 73 | } 74 | set { 75 | storage[id] = newValue 76 | } 77 | } 78 | 79 | mutating func removeAll(where shouldBeRemoved: (Element) throws -> Bool) rethrows { 80 | try storage.removeAll { key, value in 81 | return try shouldBeRemoved(value) 82 | } 83 | } 84 | } 85 | 86 | public extension OrderedIDSet where Element: Equatable { 87 | func contains(_ element: Element) -> Bool { 88 | storage[element.id] == element 89 | } 90 | } 91 | 92 | extension OrderedIDSet: ExpressibleByArrayLiteral { 93 | public init(arrayLiteral elements: Element...) { 94 | self = .init(elements) 95 | } 96 | } 97 | 98 | extension OrderedIDSet: Encodable where Element: Encodable { 99 | public func encode(to encoder: Encoder) throws { 100 | var container = encoder.singleValueContainer() 101 | try container.encode(Array(self)) 102 | } 103 | } 104 | 105 | extension OrderedIDSet: Decodable where Element: Decodable { 106 | public init(from decoder: Decoder) throws { 107 | let container = try decoder.singleValueContainer() 108 | let elements = try container.decode(Array.self) 109 | self = .init(elements) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditor/Support.swift: -------------------------------------------------------------------------------- 1 | import Collections 2 | import Foundation 3 | import SwiftUI 4 | 5 | extension Color { 6 | // TODO: This is silly. Replace with presentation. 7 | static let placeholderBlack = Color.black 8 | static let placeholderWhite = Color.white 9 | static let placeholder1 = Color.purple 10 | } 11 | 12 | public extension Path { 13 | static func wire(start: CGPoint, end: CGPoint) -> Path { 14 | Path { path in 15 | path.move(to: start) 16 | if abs(start.x - end.x) < 5 { 17 | path.addLine(to: end) 18 | } 19 | else { 20 | path.addCurve(to: end, control1: CGPoint(x: (start.x + end.x) / 2, y: start.y), control2: CGPoint(x: (start.x + end.x) / 2, y: end.y)) 21 | } 22 | } 23 | } 24 | } 25 | 26 | public struct AnimatedWire: View { 27 | let start: CGPoint 28 | let end: CGPoint 29 | let foreground: Color 30 | // TODO: Use Environment.backgroundStyle 31 | let background: Color 32 | 33 | @State 34 | var phase: CGFloat = 0 35 | 36 | public init(start: CGPoint, end: CGPoint, foreground: Color, background: Color = Color.white.opacity(0.75), phase: CGFloat = 0) { 37 | self.start = start 38 | self.end = end 39 | self.phase = phase 40 | self.foreground = foreground 41 | self.background = background 42 | } 43 | 44 | public var body: some View { 45 | let path = Path.wire(start: start, end: end) 46 | path.stroke(foreground, style: StrokeStyle(lineWidth: 4, lineCap: .round, dash: [10], dashPhase: phase)) 47 | .onAppear { 48 | withAnimation(.linear.repeatForever(autoreverses: false)) { 49 | phase -= 20 50 | } 51 | } 52 | .background(path.stroke(background, style: StrokeStyle(lineWidth: 6, lineCap: .round))) 53 | } 54 | } 55 | 56 | extension OrderedDictionary where Value: Identifiable, Key == Value.ID { 57 | @discardableResult 58 | mutating func insert(_ value: Value) -> (inserted: Bool, memberAfterIndex: Value) { 59 | if let oldMember = self[value.id] { 60 | self[value.id] = value 61 | return (false, oldMember) 62 | } 63 | else { 64 | self[value.id] = value 65 | return (true, value) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditorDemo/Actions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftNodeEditor 3 | import SwiftUI 4 | 5 | struct ContextMenuForNodeModifier: ViewModifier { 6 | @Binding 7 | var node: MyNode 8 | 9 | @EnvironmentObject 10 | private var model: CanvasModel 11 | 12 | func body(content: Content) -> some View { 13 | content.contextMenu { 14 | Button("Delete") { 15 | model.nodes.removeAll { node.id == $0.id } 16 | model.wires.removeAll { node.sockets.map(\.id).contains($0.destinationSocket.id) } 17 | } 18 | Divider() 19 | Text("Colour") 20 | Button(action: { node.color = Color(hue: Double.random(in: 0 ..< 1), saturation: 1, brightness: 1) }, label: { Text("Random") }) 21 | Button(action: { node.color = Color.red }, label: { Text("Red") }) 22 | Button(action: { node.color = Color.green }, label: { Text("Green") }) 23 | Button(action: { node.color = Color.blue }, label: { Text("Blue") }) 24 | Button(action: { node.color = Color.purple }, label: { Text("Purple") }) 25 | } 26 | } 27 | } 28 | 29 | extension View { 30 | func contextMenu(for node: Binding) -> some View { 31 | modifier(ContextMenuForNodeModifier(node: node)) 32 | } 33 | } 34 | 35 | struct ContextMenuForSocketModifier: ViewModifier { 36 | @Binding 37 | var socket: MySocket 38 | 39 | @Binding 40 | var node: MyNode 41 | 42 | @EnvironmentObject 43 | private var model: CanvasModel 44 | 45 | func body(content: Content) -> some View { 46 | content.contextMenu { 47 | Button("Delete", action: { 48 | node.sockets.removeAll { socket.id == $0.id } 49 | model.wires.removeAll { socket.id == $0.sourceSocket.id || socket.id == $0.destinationSocket.id } 50 | }) 51 | } 52 | } 53 | } 54 | 55 | extension View { 56 | func contextMenu(for socket: Binding, of node: Binding) -> some View { 57 | modifier(ContextMenuForSocketModifier(socket: socket, node: node)) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditorDemo/BasicPresentation.swift: -------------------------------------------------------------------------------- 1 | import SwiftNodeEditor 2 | import SwiftUI 3 | 4 | public struct BasicPresentation: PresentationProtocol { 5 | public func content(for node: Binding, configuration: NodeConfiguration) -> some View { 6 | NodeView(node: node, configuration: configuration) 7 | } 8 | 9 | public func content(for wire: Binding, configuration: WireConfiguration) -> some View { 10 | WireView(wire: wire, configuration: configuration) 11 | } 12 | 13 | public func content(for socket: MySocket) -> some View { 14 | Circle().stroke(Color.black, lineWidth: 4) 15 | .background(Circle().fill(Color.white)) 16 | } 17 | 18 | public func content(forPin socket: MySocket) -> some View { 19 | let radius = 4 20 | return Path { path in 21 | path.addEllipse(in: CGRect(origin: CGPoint(x: -radius, y: -radius), size: CGSize(width: radius * 2, height: radius * 2))) 22 | } 23 | .fill(Color.black) 24 | } 25 | 26 | struct NodeView: View { 27 | @Binding 28 | var node: MyNode 29 | 30 | let configuration: NodeConfiguration 31 | 32 | @State 33 | var editing: Bool = false 34 | 35 | var body: some View { 36 | VStack { 37 | FinderStyleTextField(text: $node.name, isEditing: $editing) 38 | .frame(maxWidth: 80) 39 | HStack { 40 | ForEach(node.sockets) { socket in 41 | SocketView(node: _node, socket: socket) 42 | .contextMenu(for: .constant(socket), of: $node) 43 | } 44 | Button { 45 | node.sockets.append(MySocket()) 46 | } 47 | label: { 48 | Image(systemName: "plus.circle") 49 | } 50 | .buttonStyle(BorderlessButtonStyle()) 51 | } 52 | } 53 | .padding() 54 | .cornerRadius(16) 55 | .background(node.color.cornerRadius(14)) 56 | .padding(4) 57 | .background(configuration.selected ? Color.accentColor.cornerRadius(18) : Color.white.opacity(0.75).cornerRadius(16)) 58 | .contextMenu(for: $node) 59 | .onChange(of: configuration.selected) { selected in 60 | if selected == false { 61 | editing = false 62 | } 63 | } 64 | .onChange(of: editing) { editing in 65 | if editing == true { 66 | configuration.selected = true 67 | } 68 | } 69 | } 70 | } 71 | 72 | struct WireView: View { 73 | @Binding 74 | var wire: MyWire 75 | 76 | let configuration: WireConfiguration 77 | 78 | var body: some View { 79 | let path = Path.wire(start: configuration.start, end: configuration.end) 80 | path.stroke(wire.color, style: StrokeStyle(lineWidth: 4, lineCap: .round)) 81 | .background(path.stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: 6, lineCap: .round))) 82 | } 83 | } 84 | } 85 | 86 | public struct FinderStyleTextField: View { 87 | @Binding 88 | var text: String 89 | 90 | @Binding 91 | var isEditing: Bool 92 | 93 | public init(text: Binding, isEditing: Binding) { 94 | self._text = text 95 | self._isEditing = isEditing 96 | } 97 | 98 | public var body: some View { 99 | switch isEditing { 100 | case false: 101 | Text(verbatim: text) 102 | .onTapGesture { 103 | isEditing = true 104 | } 105 | case true: 106 | TextField("text", text: _text) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditorDemo/Document.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RegexBuilder 3 | import SwiftNodeEditor 4 | import SwiftUI 5 | import UniformTypeIdentifiers 6 | 7 | extension UTType { 8 | static let nodeGraph = UTType(exportedAs: "io.schwa.nodegraph") // TODO: 9 | } 10 | 11 | public struct GraphDocument: FileDocument { 12 | public static let readableContentTypes: [UTType] = [.nodeGraph] 13 | 14 | public var nodes: [MyNode] = [] 15 | public var wires: [MyWire] = [] 16 | 17 | public init() { 18 | } 19 | 20 | public init(configuration: ReadConfiguration) throws { 21 | guard let data = configuration.file.regularFileContents else { 22 | fatalError() 23 | } 24 | 25 | let graph = try JSONDecoder().decode(Graph.self, from: data) 26 | nodes = graph.nodes 27 | wires = graph.wires 28 | } 29 | 30 | public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 31 | let graph = Graph(nodes: nodes, wires: wires) 32 | let data = try JSONEncoder().encode(graph) 33 | return .init(regularFileWithContents: data) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditorDemo/Model.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftNodeEditor 3 | import SwiftUI 4 | 5 | public struct MyNode: Identifiable, NodeProtocol, Codable { 6 | public var id = UUID() 7 | public var name: String 8 | public var position: CGPoint 9 | public var sockets: [MySocket] = [ 10 | Socket(), 11 | Socket(), 12 | ] 13 | public var color: Color = .mint 14 | 15 | public init(position: CGPoint) { 16 | name = "Node \(LolID())" 17 | self.position = position 18 | } 19 | } 20 | 21 | public struct MyWire: WireProtocol, Codable { 22 | public var id = UUID() 23 | public var color: Color 24 | public let sourceSocket: MySocket 25 | public let destinationSocket: MySocket 26 | 27 | public init(sourceSocket: MySocket, destinationSocket: MySocket) { 28 | color = .black 29 | self.sourceSocket = sourceSocket 30 | self.destinationSocket = destinationSocket 31 | } 32 | 33 | public init(color: Color, sourceSocket: MySocket, destinationSocket: MySocket) { 34 | self.color = color 35 | self.sourceSocket = sourceSocket 36 | self.destinationSocket = destinationSocket 37 | } 38 | } 39 | 40 | public struct MySocket: SocketProtocol, Codable { 41 | public var id = UUID() 42 | 43 | public init() {} 44 | } 45 | 46 | // MARK: - 47 | 48 | public class CanvasModel: ObservableObject { 49 | @Published 50 | public var nodes: [MyNode] = [] // TODO: We do a lot of brute force lookup via id - make into a "ordered id set" type container 51 | 52 | @Published 53 | public var wires: [MyWire] = [] // TODO: We do a lot of brute force lookup via id - make into a "ordered id set" type container 54 | 55 | @Published 56 | public var selection: Set = [] 57 | 58 | public init() { 59 | nodes = [ 60 | MyNode(position: CGPoint(x: 100, y: 100)), 61 | MyNode(position: CGPoint(x: 200, y: 100)), 62 | MyNode(position: CGPoint(x: 300, y: 100)), 63 | MyNode(position: CGPoint(x: 100, y: 200)), 64 | MyNode(position: CGPoint(x: 200, y: 200)), 65 | MyNode(position: CGPoint(x: 300, y: 200)), 66 | ] 67 | wires = [ 68 | MyWire(sourceSocket: nodes[0].sockets[0], destinationSocket: nodes[1].sockets[0]), 69 | ] 70 | } 71 | } 72 | 73 | public struct Graph: Codable { 74 | public var nodes: [MyNode] = [] 75 | public var wires: [MyWire] = [] 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditorDemo/NodeGraphEditorDemoView.swift: -------------------------------------------------------------------------------- 1 | import SwiftNodeEditor 2 | import SwiftUI 3 | 4 | public struct NodeGraphEditorDemoView: View { 5 | @Binding 6 | var document: GraphDocument 7 | 8 | @StateObject 9 | var model = CanvasModel() 10 | 11 | enum PresentationMode { 12 | case basic 13 | case radial 14 | } 15 | 16 | @State 17 | var presentationMode: PresentationMode = .basic 18 | 19 | @State 20 | var selection: Set = [] 21 | 22 | public init(document: Binding) { 23 | _document = document 24 | presentationMode = .basic 25 | } 26 | 27 | public var body: some View { 28 | Group { 29 | #if os(macOS) 30 | HSplitView { 31 | editorView 32 | .frame(minWidth: 320, minHeight: 240) 33 | .layoutPriority(1) 34 | 35 | detailView 36 | .ignoresSafeArea(.all, edges: .top) 37 | } 38 | #elseif os(iOS) 39 | editorView 40 | #endif 41 | } 42 | .toolbar { 43 | toolbar 44 | } 45 | .environmentObject(model) 46 | .onAppear { 47 | model.nodes = document.nodes 48 | model.wires = document.wires 49 | } 50 | .onReceive(model.objectWillChange) { 51 | document.nodes = model.nodes 52 | document.wires = model.wires 53 | } 54 | } 55 | 56 | @ViewBuilder 57 | var toolbar: some View { 58 | Button(systemImage: "plus") { 59 | model.nodes.append(MyNode(position: CGPoint(x: 100, y: 100))) 60 | } 61 | 62 | Button(systemImage: "paintpalette") { 63 | model.nodes = model.nodes.map { 64 | var node = $0 65 | node.color = Color(hue: Double.random(in: 0 ..< 1), saturation: 1, brightness: 1) 66 | return node 67 | } 68 | model.wires = model.wires.map { 69 | var wire = $0 70 | wire.color = Color(hue: Double.random(in: 0 ..< 1), saturation: 1, brightness: 1) 71 | return wire 72 | } 73 | } 74 | 75 | Picker("Mode", selection: $presentationMode) { 76 | Text("Basic").tag(PresentationMode.basic) 77 | Text("Radial").tag(PresentationMode.radial) 78 | } 79 | .pickerStyle(MenuPickerStyle()) 80 | } 81 | 82 | @ViewBuilder 83 | var editorView: some View { 84 | Group { 85 | switch presentationMode { 86 | case .basic: 87 | NodeGraphEditorView(nodes: $model.nodes, wires: $model.wires, selection: $selection, presentation: BasicPresentation()) 88 | case .radial: 89 | NodeGraphEditorView(nodes: $model.nodes, wires: $model.wires, selection: $selection, presentation: RadialPresentation()) 90 | } 91 | } 92 | .backgroundStyle(.gray.opacity(0.05)) 93 | } 94 | 95 | @ViewBuilder 96 | var detailView: some View { 97 | if let id = model.selection.first { 98 | let binding = Binding { 99 | model.nodes.first { id == $0.id }! 100 | } 101 | set: { node in 102 | let index = model.nodes.firstIndex(where: { $0.id == node.id })! 103 | model.nodes[index] = node 104 | } 105 | NodeInfoView(node: binding) 106 | } 107 | } 108 | } 109 | 110 | // MARK: - 111 | 112 | struct NodeInfoView: View { 113 | @Binding 114 | var node: MyNode 115 | 116 | var body: some View { 117 | VStack { 118 | TextField("Node", text: $node.name) 119 | ColorPicker("Color", selection: $node.color).fixedSize() 120 | } 121 | .padding() 122 | .frame(maxWidth: .infinity, maxHeight: .infinity) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditorDemo/RadialPresentation.swift: -------------------------------------------------------------------------------- 1 | import SwiftNodeEditor 2 | import SwiftUI 3 | 4 | struct RadialPresentation: PresentationProtocol { 5 | func content(for node: Binding, configuration: NodeConfiguration) -> some View { 6 | NodeView(node: node, configuration: configuration) 7 | } 8 | 9 | func content(for wire: Binding, configuration: WireConfiguration) -> some View { 10 | WireView(wire: wire, configuration: configuration) 11 | } 12 | 13 | func content(for socket: MySocket) -> some View { 14 | Circle().stroke(Color.black, lineWidth: 4) 15 | .background(Circle().fill(Color.white)) 16 | } 17 | 18 | func content(forPin socket: MySocket) -> some View { 19 | let radius = 4 20 | return Path { path in 21 | path.addEllipse(in: CGRect(origin: CGPoint(x: -radius, y: -radius), size: CGSize(width: radius * 2, height: radius * 2))) 22 | } 23 | .fill(Color.black) 24 | } 25 | 26 | struct NodeView: View { 27 | @Binding 28 | var node: MyNode 29 | 30 | let configuration: NodeConfiguration 31 | 32 | var body: some View { 33 | let radius = 36.0 34 | let angle = Angle(degrees: 360 / Double(node.sockets.count + 1)) 35 | let enumeration = Array(node.sockets.enumerated()) 36 | 37 | return ZStack { 38 | Text(verbatim: node.name) 39 | ZStack { 40 | Button { 41 | node.sockets.append(MySocket()) 42 | } 43 | label: { 44 | Image(systemName: "plus.circle") 45 | } 46 | .buttonStyle(BorderlessButtonStyle()) 47 | .background(Circle().fill(Color.white)) 48 | .offset(angle: angle * 0, radius: radius) 49 | ForEach(enumeration, id: \.0) { index, socket in 50 | SocketView(node: _node, socket: socket) 51 | .offset(angle: angle * Double(index + 1), radius: radius) 52 | .contextMenu(for: .constant(socket), of: $node) 53 | } 54 | } 55 | } 56 | .background(Circle().fill(node.color).frame(width: radius * 2, height: radius * 2)) 57 | .background(Circle().fill(configuration.selected ? Color.accentColor : Color.white.opacity(0.75)).frame(width: radius * 2 + 8, height: radius * 2 + 8)) 58 | .contextMenu(for: $node) 59 | } 60 | } 61 | 62 | struct WireView: View { 63 | @Binding 64 | var wire: MyWire 65 | 66 | let configuration: WireConfiguration 67 | 68 | var body: some View { 69 | let path = Path.wire(start: configuration.start, end: configuration.end) 70 | path.stroke(wire.color, style: StrokeStyle(lineWidth: 4, lineCap: .round)) 71 | .background(path.stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: 6, lineCap: .round))) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftNodeEditorDemo/Support.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import Algorithms 4 | 5 | // TODO: use LolUID from Everything 6 | public struct LolID: Hashable { 7 | private var rawValue: Int 8 | private static var nextValue: Int = 0 9 | private static var lock = os_unfair_lock_t() 10 | 11 | public init() { 12 | rawValue = LolID.lock.withLock { 13 | defer { 14 | LolID.nextValue += 1 15 | } 16 | return LolID.nextValue 17 | } 18 | } 19 | } 20 | 21 | extension LolID: CustomStringConvertible { 22 | public var description: String { 23 | "\(rawValue)" 24 | } 25 | } 26 | 27 | // MARK: - 28 | 29 | extension UnsafeMutablePointer where Pointee == os_unfair_lock { 30 | init() { 31 | self = UnsafeMutablePointer.allocate(capacity: 1) 32 | initialize(to: os_unfair_lock()) 33 | } 34 | 35 | func lock() { 36 | os_unfair_lock_lock(self) 37 | } 38 | 39 | func unlock() { 40 | os_unfair_lock_unlock(self) 41 | } 42 | 43 | func withLock(_ transaction: () throws -> R) rethrows -> R { 44 | lock() 45 | defer { 46 | unlock() 47 | } 48 | return try transaction() 49 | } 50 | } 51 | 52 | extension View { 53 | func offset(angle: Angle, radius: CGFloat) -> some View { 54 | offset(x: cos(angle.radians) * radius, y: sin(angle.radians) * radius) 55 | } 56 | } 57 | 58 | enum DynamicColor: String, RawRepresentable, CaseIterable { 59 | case red 60 | case orange 61 | case yellow 62 | case green 63 | case mint 64 | case teal 65 | case cyan 66 | case blue 67 | case indigo 68 | case purple 69 | case pink 70 | case brown 71 | case white 72 | case gray 73 | case black 74 | case clear 75 | case primary 76 | case secondary 77 | 78 | init?(color: Color) { 79 | switch color { 80 | case .red: 81 | self = .red 82 | case .orange: 83 | self = .orange 84 | case .yellow: 85 | self = .yellow 86 | case .green: 87 | self = .green 88 | case .mint: 89 | self = .mint 90 | case .teal: 91 | self = .teal 92 | case .cyan: 93 | self = .cyan 94 | case .blue: 95 | self = .blue 96 | case .indigo: 97 | self = .indigo 98 | case .purple: 99 | self = .purple 100 | case .pink: 101 | self = .pink 102 | case .brown: 103 | self = .brown 104 | case .white: 105 | self = .white 106 | case .gray: 107 | self = .gray 108 | case .black: 109 | self = .black 110 | case .clear: 111 | self = .clear 112 | case .primary: 113 | self = .primary 114 | case .secondary: 115 | self = .secondary 116 | default: 117 | return nil 118 | } 119 | } 120 | 121 | var color: Color { 122 | switch self { 123 | case .red: 124 | return .red 125 | case .orange: 126 | return .orange 127 | case .yellow: 128 | return .yellow 129 | case .green: 130 | return .green 131 | case .mint: 132 | return .mint 133 | case .teal: 134 | return .teal 135 | case .cyan: 136 | return .cyan 137 | case .blue: 138 | return .blue 139 | case .indigo: 140 | return .indigo 141 | case .purple: 142 | return .purple 143 | case .pink: 144 | return .pink 145 | case .brown: 146 | return .brown 147 | case .white: 148 | return .white 149 | case .gray: 150 | return .gray 151 | case .black: 152 | return .black 153 | case .clear: 154 | return .clear 155 | case .primary: 156 | return .primary 157 | case .secondary: 158 | return .secondary 159 | } 160 | } 161 | } 162 | 163 | @available(macOS 13.0, *) 164 | extension Color: Codable { 165 | public func encode(to encoder: Encoder) throws { 166 | if let dynamicColor = DynamicColor(color: self) { 167 | var container = encoder.singleValueContainer() 168 | try container.encode(dynamicColor.rawValue) 169 | } 170 | else { 171 | guard let cgColor else { 172 | fatalError() 173 | } 174 | // TODO: This assumes RGBA 175 | guard let components = cgColor.components else { 176 | fatalError() 177 | } 178 | let hex = "#" + components.map { UInt8($0 * 255) }.map { ("0" + String($0, radix: 16)).suffix(2) }.joined() 179 | var container = encoder.singleValueContainer() 180 | try container.encode(hex) 181 | } 182 | } 183 | 184 | public init(from decoder: Decoder) throws { 185 | // This is one way of extract three integers from a string :-) 186 | let container = try decoder.singleValueContainer() 187 | let string = try container.decode(String.self) 188 | 189 | if let dynamicColor = DynamicColor(rawValue: string) { 190 | self = dynamicColor.color 191 | } 192 | else { 193 | guard string.hasPrefix("#") else { 194 | fatalError() 195 | } 196 | let stringComponents = string.dropFirst(1) 197 | let components: [CGFloat] 198 | switch stringComponents.count { 199 | case 3: 200 | components = stringComponents.map { UInt8(String($0), radix: 16)! }.map { CGFloat($0) / 255 } 201 | case 6, 8: 202 | components = stringComponents.chunks(ofCount: 2).map { UInt8(String($0), radix: 16)! }.map { CGFloat($0) / 255 } 203 | default: 204 | fatalError() 205 | } 206 | let cgColor = CGColor(red: components[0], green: components[1], blue: components[2], alpha: components.count > 3 ? components[3] : 1.0) 207 | self = Color(cgColor: cgColor) 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /Tests/SwiftNodeEditorTests/SwiftNodeEditorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SwiftNodeEditor 2 | import XCTest 3 | 4 | final class OrderedIDSetTests: XCTestCase { 5 | func test1() throws { 6 | struct Thing: Identifiable, Equatable { 7 | let id: String 8 | let value: String 9 | } 10 | 11 | var set = OrderedIDSet() 12 | XCTAssertEqual(set.count, 0) 13 | XCTAssertTrue(set.isEmpty) 14 | set.insert(.init(id: "A", value: "1")) 15 | XCTAssertFalse(set.isEmpty) 16 | XCTAssertTrue(set.contains(set[0])) 17 | XCTAssertEqual(set[0].value, "1") 18 | XCTAssertEqual(set[id: "A"]?.value, "1") 19 | set.insert(.init(id: "B", value: "2")) 20 | set.insert(.init(id: "C", value: "3")) 21 | XCTAssertEqual(set.count, 3) 22 | XCTAssertEqual(set.map(\.value), ["1", "2", "3"]) 23 | set[id: "B"] = nil 24 | XCTAssertEqual(set.count, 2) 25 | XCTAssertEqual(set.map(\.value), ["1", "3"]) 26 | set.insert(.init(id: "B", value: "4")) 27 | XCTAssertEqual(set.map(\.value), ["1", "3", "4"]) 28 | } 29 | } 30 | --------------------------------------------------------------------------------