├── .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 | 
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 |
--------------------------------------------------------------------------------