├── .gitattributes ├── .gitignore ├── Banner.jpg ├── LICENSE ├── LogicBoard.swiftpm ├── .swiftpm │ ├── playgrounds │ │ ├── CachedManifest.plist │ │ ├── DocumentThumbnail.plist │ │ ├── DocumentThumbnail.png │ │ └── Workspace.plist │ └── xcode │ │ ├── package.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcuserdata │ │ │ └── joseadolfot.xcuserdatad │ │ │ ├── IDEFindNavigatorScopes.plist │ │ │ └── UserInterfaceState.xcuserstate │ │ └── xcuserdata │ │ └── joseadolfot.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist ├── Assets.xcassets │ ├── AND.imageset │ │ ├── AND.png │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon.png │ │ └── Contents.json │ ├── BUFFER.imageset │ │ ├── BUFFER.png │ │ └── Contents.json │ ├── Contents.json │ ├── InputFalse.imageset │ │ ├── Contents.json │ │ └── InputFalse.png │ ├── InputTrue.imageset │ │ ├── Contents.json │ │ └── InputTrue.png │ ├── NAND.imageset │ │ ├── Contents.json │ │ └── NAND.png │ ├── NOR.imageset │ │ ├── Contents.json │ │ └── NOR.png │ ├── NOT.imageset │ │ ├── Contents.json │ │ └── NOT.png │ ├── OR.imageset │ │ ├── Contents.json │ │ └── OR.png │ ├── OutputFalse.imageset │ │ ├── Contents.json │ │ └── OutputFalse.png │ ├── OutputTrue.imageset │ │ ├── Contents.json │ │ └── OutputTrue.png │ ├── XNOR.imageset │ │ ├── Contents.json │ │ └── XNOR.png │ ├── XOR.imageset │ │ ├── Contents.json │ │ └── XOR.png │ └── background.imageset │ │ ├── Contents.json │ │ └── background.jpg ├── LogicApp.swift ├── LogicBoard │ ├── BoardState.swift │ ├── Circuit │ │ ├── Circuit+Connection.swift │ │ ├── Circuit+Device.swift │ │ ├── Circuit+Examples.swift │ │ ├── Circuit+Solver.swift │ │ ├── Circuit.swift │ │ ├── CircuitData.swift │ │ └── CircuitDocument.swift │ ├── Components │ │ ├── Connection │ │ │ └── Connection.swift │ │ └── Device │ │ │ ├── Device+CanvasScene.swift │ │ │ ├── Device.swift │ │ │ ├── DeviceName.swift │ │ │ ├── DevicePin.swift │ │ │ ├── Gate │ │ │ ├── And.swift │ │ │ ├── Buffer.swift │ │ │ ├── Nand.swift │ │ │ ├── Nor.swift │ │ │ ├── Not.swift │ │ │ ├── Or.swift │ │ │ ├── Xnor.swift │ │ │ └── Xor.swift │ │ │ ├── Input │ │ │ └── PushButton.swift │ │ │ └── Output │ │ │ └── Display.swift │ ├── Extensions │ │ └── Nameable.swift │ ├── LogicBoard.swift │ └── LogicEnvironment.swift ├── Package.swift ├── Popover │ ├── LICENSE │ └── Sources │ │ ├── Popover+Attributes.swift │ │ ├── Popover+Calculations.swift │ │ ├── Popover+Context.swift │ │ ├── Popover+Lifecycle.swift │ │ ├── Popover+Positioning.swift │ │ ├── Popover.swift │ │ ├── PopoverContainerView.swift │ │ ├── PopoverGestureContainer.swift │ │ ├── PopoverModel.swift │ │ ├── PopoverUtilities.swift │ │ ├── PopoverWindows.swift │ │ ├── Popovers.swift │ │ ├── SwiftUI │ │ ├── FrameTag.swift │ │ ├── Modifiers.swift │ │ └── Readers.swift │ │ └── Templates │ │ ├── Alert.swift │ │ ├── Blur.swift │ │ ├── Container.swift │ │ ├── Extensions.swift │ │ ├── Hero.swift │ │ ├── Menu │ │ ├── Menu+SwiftUI.swift │ │ ├── Menu+UIKit.swift │ │ ├── Menu.swift │ │ └── Model │ │ │ ├── MenuGestureModel.swift │ │ │ └── MenuModel.swift │ │ ├── Shadow.swift │ │ ├── Shapes.swift │ │ ├── Templates.swift │ │ └── Views.swift └── Views │ ├── BoardView.swift │ ├── Canvas │ ├── CanvasScene+Animation.swift │ ├── CanvasScene+Color.swift │ ├── CanvasScene+Device.swift │ ├── CanvasScene+Texture.swift │ ├── CanvasScene.swift │ ├── CanvasView.swift │ └── Gestures │ │ ├── Pan+OneFinger.swift │ │ ├── Pan+TwoFinger.swift │ │ ├── Pencil.swift │ │ ├── Pinch.swift │ │ └── Tap.swift │ ├── Color │ └── Color.swift │ ├── Controls │ └── ActionControlView.swift │ ├── Pickers │ ├── CircuitPickerView.swift │ ├── DevicePickerView.swift │ └── SystemPickerView.swift │ └── ViewStyles │ ├── ButtonStyles │ ├── ActionButtonStyle.swift │ ├── CircleButtonStyle.swift │ ├── CircuitButtonStyle.swift │ ├── NoFlickerButtonStyle.swift │ └── SystemButtonStyle.swift │ └── LabelStyles │ └── ActionLabelStyle.swift ├── README.md └── Screenshots ├── Screenshot1.gif ├── Screenshot2.gif └── Screenshot3.gif /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /Banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/Banner.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jose Adolfo Talactac 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/playgrounds/CachedManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CachedManifest 6 | 7 | manifestData 8 | 9 | eyJkZXBlbmRlbmNpZXMiOlt7InNvdXJjZUNvbnRyb2wiOlt7ImlkZW50aXR5 10 | IjoicG9wb3ZlcnMiLCJsb2NhdGlvbiI6eyJyZW1vdGUiOlsiaHR0cHM6XC9c 11 | L2dpdGh1Yi5jb21cL2FoZXplXC9Qb3BvdmVycyJdfSwicHJvZHVjdEZpbHRl 12 | ciI6bnVsbCwicmVxdWlyZW1lbnQiOnsicmFuZ2UiOlt7Imxvd2VyQm91bmQi 13 | OiIxLjMuMiIsInVwcGVyQm91bmQiOiIyLjAuMCJ9XX19XX1dLCJkaXNwbGF5 14 | TmFtZSI6IkxvZ2ljQm9hcmQiLCJwYWNrYWdlS2luZCI6eyJyb290Ijp7fX0s 15 | InBsYXRmb3JtcyI6W3sib3B0aW9ucyI6W10sInBsYXRmb3JtTmFtZSI6Imlv 16 | cyIsInZlcnNpb24iOiIxNi4wIn1dLCJwcm9kdWN0cyI6W3sibmFtZSI6Ikxv 17 | Z2ljQm9hcmQiLCJzZXR0aW5ncyI6W3sidGVhbUlkZW50aWZpZXIiOlsiNlNL 18 | NFk0RFRNRSJdfSx7ImRpc3BsYXlWZXJzaW9uIjpbIjEuMCJdfSx7ImJ1bmRs 19 | ZVZlcnNpb24iOlsiMSJdfSx7ImlPU0FwcEluZm8iOlt7ImFjY2VudENvbG9y 20 | Ijp7InByZXNldENvbG9yIjp7InByZXNldENvbG9yIjp7InJhd1ZhbHVlIjoi 21 | YnJvd24ifX19LCJhcHBDYXRlZ29yeSI6eyJyYXdWYWx1ZSI6InB1YmxpYy5h 22 | cHAtY2F0ZWdvcnkuZWR1Y2F0aW9uIn0sImFwcEljb24iOnsiYXNzZXQiOnsi 23 | bmFtZSI6IkFwcEljb24ifX0sImNhcGFiaWxpdGllcyI6W3siZmlsZUFjY2Vz 24 | c0xvY2F0aW9uIjoiZG93bmxvYWRzRm9sZGVyIiwiZmlsZUFjY2Vzc01vZGUi 25 | OiJyZWFkV3JpdGUiLCJwdXJwb3NlIjoiZmlsZUFjY2VzcyJ9LHsiZmlsZUFj 26 | Y2Vzc0xvY2F0aW9uIjoidXNlclNlbGVjdGVkRmlsZXMiLCJmaWxlQWNjZXNz 27 | TW9kZSI6InJlYWRXcml0ZSIsInB1cnBvc2UiOiJmaWxlQWNjZXNzIn1dLCJz 28 | dXBwb3J0ZWREZXZpY2VGYW1pbGllcyI6WyJwYWQiLCJwaG9uZSJdLCJzdXBw 29 | b3J0ZWRJbnRlcmZhY2VPcmllbnRhdGlvbnMiOlt7InBvcnRyYWl0Ijp7fX0s 30 | eyJsYW5kc2NhcGVSaWdodCI6e319LHsibGFuZHNjYXBlTGVmdCI6e319LHsi 31 | cG9ydHJhaXRVcHNpZGVEb3duIjp7ImNvbmRpdGlvbiI6eyJkZXZpY2VGYW1p 32 | bGllcyI6WyJwYWQiXX19fV19XX1dLCJ0YXJnZXRzIjpbIkFwcE1vZHVsZSJd 33 | LCJ0eXBlIjp7ImV4ZWN1dGFibGUiOm51bGx9fV0sInRhcmdldE1hcCI6eyJB 34 | cHBNb2R1bGUiOnsiZGVwZW5kZW5jaWVzIjpbeyJwcm9kdWN0IjpbIlBvcG92 35 | ZXJzIiwiUG9wb3ZlcnMiLG51bGwsbnVsbF19XSwiZXhjbHVkZSI6W10sIm5h 36 | bWUiOiJBcHBNb2R1bGUiLCJwYXRoIjoiLiIsInJlc291cmNlcyI6W3sicGF0 37 | aCI6IlJlc291cmNlcyIsInJ1bGUiOnsicHJvY2VzcyI6e319fV0sInNldHRp 38 | bmdzIjpbXSwidHlwZSI6ImV4ZWN1dGFibGUifX0sInRhcmdldHMiOlt7ImRl 39 | cGVuZGVuY2llcyI6W3sicHJvZHVjdCI6WyJQb3BvdmVycyIsIlBvcG92ZXJz 40 | IixudWxsLG51bGxdfV0sImV4Y2x1ZGUiOltdLCJuYW1lIjoiQXBwTW9kdWxl 41 | IiwicGF0aCI6Ii4iLCJyZXNvdXJjZXMiOlt7InBhdGgiOiJSZXNvdXJjZXMi 42 | LCJydWxlIjp7InByb2Nlc3MiOnt9fX1dLCJzZXR0aW5ncyI6W10sInR5cGUi 43 | OiJleGVjdXRhYmxlIn1dLCJ0b29sc1ZlcnNpb24iOnsiX3ZlcnNpb24iOiI1 44 | LjcuMCJ9fQ== 45 | 46 | manifestHash 47 | 48 | jbyhkwndT6RWd4hKxKRJlgJSFyHaYdEebuUn2YuRIXI= 49 | 50 | schemaVersion 51 | 4 52 | swiftPMVersionString 53 | 5.8.0 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/playgrounds/DocumentThumbnail.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DocumentThumbnailConfiguration 6 | 7 | accentColorHash 8 | ukeIsiaqjcLm3HQki7n2GM+oyVngwmwUe+SPaDmgsIg= 9 | appIconHash 10 | aAneklUa6ah6uQ25MydaKTnGTrM2wgW9IrnWWr7/Wys= 11 | thumbnailIsPrerendered 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/playgrounds/DocumentThumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/.swiftpm/playgrounds/DocumentThumbnail.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/playgrounds/Workspace.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppSettings 6 | 7 | appIconPlaceholderGlyphName 8 | paper 9 | appSettingsVersion 10 | 1 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/xcode/package.xcworkspace/xcuserdata/joseadolfot.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/xcode/package.xcworkspace/xcuserdata/joseadolfot.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/.swiftpm/xcode/package.xcworkspace/xcuserdata/joseadolfot.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/xcode/xcuserdata/joseadolfot.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/.swiftpm/xcode/xcuserdata/joseadolfot.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | LogicBoard.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | AppModule 16 | 17 | primary 18 | 19 | 20 | LogicBoard 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/AND.imageset/AND.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/AND.imageset/AND.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/AND.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AND.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/BUFFER.imageset/BUFFER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/BUFFER.imageset/BUFFER.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/BUFFER.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "BUFFER.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/InputFalse.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "InputFalse.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/InputFalse.imageset/InputFalse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/InputFalse.imageset/InputFalse.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/InputTrue.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "InputTrue.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/InputTrue.imageset/InputTrue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/InputTrue.imageset/InputTrue.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/NAND.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "NAND.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/NAND.imageset/NAND.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/NAND.imageset/NAND.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/NOR.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "NOR.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/NOR.imageset/NOR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/NOR.imageset/NOR.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/NOT.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "NOT.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/NOT.imageset/NOT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/NOT.imageset/NOT.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/OR.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "OR.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/OR.imageset/OR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/OR.imageset/OR.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/OutputFalse.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "OutputFalse.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/OutputFalse.imageset/OutputFalse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/OutputFalse.imageset/OutputFalse.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/OutputTrue.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "OutputTrue.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/OutputTrue.imageset/OutputTrue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/OutputTrue.imageset/OutputTrue.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/XNOR.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "XNOR.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/XNOR.imageset/XNOR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/XNOR.imageset/XNOR.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/XOR.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "XOR.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/XOR.imageset/XOR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/XOR.imageset/XOR.png -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/background.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "background.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Assets.xcassets/background.imageset/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/LogicBoard.swiftpm/Assets.xcassets/background.imageset/background.jpg -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct MyApp: App { 5 | let envi = LogicEnvironment() 6 | var body: some Scene { 7 | WindowGroup { 8 | BoardView() 9 | .environmentObject(envi) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/BoardState.swift: -------------------------------------------------------------------------------- 1 | public enum BoardState { 2 | case file, select, add, wire, simulate 3 | } 4 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Circuit/Circuit+Connection.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension Circuit { 4 | public func addConnection(from start: DevicePin, 5 | to end: DevicePin, 6 | points: [CGPoint]) { 7 | guard end.terminal < end.device.inputs.count 8 | && start.terminal < start.device.outputs.count else { 9 | return 10 | } 11 | let connection = Connection(from: start, 12 | to: end, 13 | points: points) 14 | connections.append(connection) 15 | canvasScene.addChild(connection.lineNode) 16 | var startDevice = start.device 17 | var endDevice = end.device 18 | 19 | 20 | startDevice.outcomingConnections[start.terminal]?.append(connection) 21 | endDevice.incomingConnections[end.terminal] = connection 22 | } 23 | 24 | public func addConnection(_ connection: Connection) { 25 | connections.append(connection) 26 | canvasScene.addChild(connection.lineNode) 27 | var startDevice = connection.start.device 28 | var endDevice = connection.end.device 29 | 30 | startDevice.outcomingConnections[connection.start.terminal]?.append(connection) 31 | endDevice.incomingConnections[connection.end.terminal] = connection 32 | } 33 | 34 | 35 | public func remove(_ connection: Connection) { 36 | guard let index = connections.firstIndex(of: connection) else { 37 | return 38 | } 39 | var start = connection.start 40 | var end = connection.end 41 | 42 | guard let startIndex = start.device.outcomingConnections[start.terminal]?.firstIndex(of: connection) 43 | else { return } 44 | 45 | start.device.outcomingConnections[start.terminal]?.remove(at: startIndex) 46 | end.device.incomingConnections[end.terminal] = nil 47 | end.device.pinNodes.enumerated().forEach { 48 | if $0 == end.terminal { 49 | $1.occupied(false) 50 | $1.colorOccupied(false) 51 | } 52 | } 53 | 54 | canvasScene.parentCircuit?.selectedConnection = nil 55 | connection.lineNode.removeFromParent() 56 | connections.remove(at: index) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Circuit/Circuit+Device.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension Circuit { 4 | public func add(_ device: inout some Device, at position: CGPoint) { 5 | devices.append(device) 6 | device.assignParentCircuit(self) 7 | canvasScene.addDevice(device, at: position) 8 | } 9 | 10 | public func remove(_ device: some Device) { 11 | for (_, connection) in device.incomingConnections { 12 | if let connection = connection { 13 | remove(connection) 14 | } 15 | } 16 | 17 | for (_, connections) in device.outcomingConnections { 18 | for connection in connections { 19 | remove(connection) 20 | } 21 | } 22 | 23 | if let index = devices.firstIndex(where: {$0.id == device.id}) { 24 | device.spriteNode.removeFromParent() 25 | devices.remove(at: index) 26 | } 27 | } 28 | 29 | public func toggleAndSolve(_ pushButton: PushButton) { 30 | pushButton.toggle() 31 | solve(pushButton) 32 | } 33 | 34 | public func resetVisited() { 35 | for i in 0..= currentLevel { 31 | return 32 | } 33 | device.level = level 34 | device.visited = true 35 | 36 | for (_, connections) in device.outcomingConnections { 37 | for connection in connections { 38 | recursiveAssignLevel(start: &connection.end.device, level: level + 1) 39 | } 40 | } 41 | } 42 | 43 | func perLevelSolve() { 44 | let keys = arrangedDevices.keys.sorted { 45 | guard let first = $0, let second = $1 else { 46 | return false 47 | } 48 | return first < second 49 | } 50 | 51 | var queue: [(DevicePin, Bool)] = [] 52 | 53 | for key in keys { 54 | guard let key = key else { 55 | guard let devices = arrangedDevices[key] else { continue } 56 | for device in devices { 57 | solve(device) 58 | } 59 | continue 60 | } 61 | 62 | guard let devices = arrangedDevices[key] else { continue } 63 | for device in devices { 64 | device.run() 65 | device.updateWire() 66 | for (_, connections) in device.outcomingConnections { 67 | for connection in connections { 68 | connection.updateColor() 69 | let endDevice = connection.end.device 70 | let endTerminal = connection.end.terminal 71 | endDevice.wireNodes[endTerminal].strokeColorTransition(to: connection.state ? .canvasGreen : .canvasYellow) 72 | if endDevice is Display { 73 | var endDevice = endDevice 74 | endDevice.inputs[0] = connection.state 75 | } else if device.id != endDevice.id { 76 | queue.append((connection.end, connection.state)) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | for item in queue { 84 | var item = item 85 | item.0.device.inputs[item.0.terminal] = item.1 86 | } 87 | resetVisited() 88 | } 89 | 90 | func solve(_ startDevice: any Device) { 91 | recursiveSolve(startDevice) 92 | resetVisited() 93 | } 94 | 95 | func recursiveSolve(_ device: any Device, 96 | path: [any Device] = []) { 97 | device.run() 98 | device.updateWire() 99 | 100 | for (_, connections) in device.outcomingConnections { 101 | for connection in connections { 102 | connection.updateColor() 103 | connection.end.device.updateWire() 104 | if !path.contains(where: {$0.id == device.id}) { 105 | connection.end.device.inputs[connection.end.terminal] = connection.start.device.outputs[connection.start.terminal] 106 | recursiveSolve(connection.end.device, path: path + [device]) 107 | } 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Circuit/Circuit.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | public class Circuit: Nameable, Identifiable, Equatable, ObservableObject { 5 | public var devices: Array = Array() 6 | public var connections: [Connection] = [Connection]() 7 | 8 | public static var count = 0 9 | public var name: String 10 | public var id: String 11 | public var isExample: Bool = false 12 | 13 | public var state: BoardState = .add { 14 | didSet { 15 | canvasScene.state = state 16 | if state == .simulate { 17 | initialSolve() 18 | devices.forEach { $0.updateWire() } 19 | connections.forEach { $0.updateColor() } 20 | } else if oldValue == .simulate { 21 | resetState() 22 | devices.forEach { $0.resetWire() } 23 | connections.forEach { $0.resetColor() } 24 | } 25 | } 26 | } 27 | @Published public var selectedDevice: (any Device)? 28 | @Published public var selectedConnection: Connection? 29 | @Published public var canvasScene: CanvasScene 30 | 31 | public var arrangedDevices: [Int? : [any Device]] = [Int? : [any Device]]() 32 | 33 | public init() { 34 | id = "CIRCUIT" + String(Circuit.count) 35 | Circuit.count += 1 36 | name = id 37 | canvasScene = CanvasScene() 38 | canvasScene.name = id 39 | canvasScene.parentCircuit = self 40 | canvasScene.scaleMode = .resizeFill 41 | canvasScene.backgroundColor = UIColor.canvasBackground 42 | } 43 | 44 | deinit { 45 | print("Deinit circuit") 46 | } 47 | 48 | public func hardReset() { 49 | resetState() 50 | initialSolve() 51 | } 52 | 53 | public func resetState() { 54 | arrangedDevices = [Int? : [any Device]]() 55 | for device in devices { 56 | var device = device 57 | for i in 0.. Bool { 73 | return lhs.id == rhs.id 74 | } 75 | 76 | public func changeState(to state: BoardState) { 77 | self.state = state 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Circuit/CircuitData.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | import UniformTypeIdentifiers 3 | import SwiftUI 4 | 5 | struct CircuitData: Codable { 6 | var name: String 7 | var deviceDatas: [DeviceData] 8 | var connectionData: [ConnectionData] 9 | 10 | func makeCircuit() -> Circuit { 11 | var circuit = Circuit() 12 | circuit.changeName(to: name) 13 | 14 | for deviceData in deviceDatas { 15 | var device = deviceData.makeDevice() 16 | circuit.add(&device, at: .zero) 17 | device.spriteNode.position = deviceData.location 18 | } 19 | 20 | for connectionData in connectionData { 21 | guard let startPoint = connectionData.points.first, 22 | let endPoint = connectionData.points.last else { 23 | continue 24 | } 25 | 26 | let startNodes = circuit.canvasScene.nodes(at: startPoint) 27 | let endNodes = circuit.canvasScene.nodes(at: endPoint) 28 | 29 | guard let startPinNode = startNodes.first(where: { $0.name?.contains("PIN") ?? false }), 30 | let endPinNode = endNodes.first(where: { $0.name?.contains("PIN") ?? false }) else { 31 | continue 32 | } 33 | 34 | guard let startDevice = startPinNode.parent?.parentDevice, 35 | let endDevice = endPinNode.parent?.parentDevice else { 36 | continue 37 | } 38 | 39 | guard let startTerminal = startPinNode.terminal, 40 | let endTerminal = endPinNode.terminal else { 41 | continue 42 | } 43 | 44 | let startDevicePin = (startDevice, startTerminal) 45 | let endDevicePin = (endDevice, endTerminal) 46 | 47 | endPinNode.occupied(true) 48 | endPinNode.colorOccupied(true) 49 | 50 | let connection = Connection(from: startDevicePin, to: endDevicePin, points: connectionData.points) 51 | circuit.addConnection(connection) 52 | } 53 | 54 | return circuit 55 | } 56 | } 57 | 58 | struct DeviceData: Codable { 59 | let deviceName : DeviceName 60 | let location: CGPoint 61 | 62 | func makeDevice() -> any Device { 63 | let device = deviceName.deviceNameToDevice() 64 | return device 65 | } 66 | } 67 | 68 | struct ConnectionData: Codable { 69 | let points: [CGPoint] 70 | } 71 | 72 | 73 | extension Circuit { 74 | func makeCircuitData() -> CircuitData { 75 | var deviceDatas = [DeviceData]() 76 | var connectionDatas = [ConnectionData]() 77 | for device in self.devices { 78 | deviceDatas.append(DeviceData(deviceName: DeviceName.deviceStringToDeviceName(string: device.deviceName), location: device.spriteNode.position)) 79 | } 80 | for connection in self.connections { 81 | connectionDatas.append(ConnectionData(points: connection.points)) 82 | } 83 | 84 | return CircuitData(name: self.name, deviceDatas: deviceDatas, connectionData: connectionDatas) 85 | } 86 | } 87 | 88 | extension UTType { 89 | static let circ = UTType(exportedAs: "com.joseadolfo.circ", conformingTo: .json) 90 | } 91 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Circuit/CircuitDocument.swift: -------------------------------------------------------------------------------- 1 | import UniformTypeIdentifiers 2 | import SwiftUI 3 | 4 | struct CircuitDocument: FileDocument { 5 | static var readableContentTypes: [UTType] = [.circ, .json] 6 | 7 | var circuitData: CircuitData 8 | 9 | init(circuitData: CircuitData) { 10 | self.circuitData = circuitData 11 | } 12 | 13 | init(configuration: ReadConfiguration) throws { 14 | guard let data = configuration.file.regularFileContents else { 15 | throw CocoaError(.fileReadCorruptFile) 16 | } 17 | circuitData = try JSONDecoder().decode(CircuitData.self, from: data) 18 | } 19 | 20 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 21 | let data = try JSONEncoder().encode(self.circuitData) 22 | let fileWrapper = FileWrapper(regularFileWithContents: data) 23 | return fileWrapper 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Connection/Connection.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Connection: Identifiable, Equatable { 4 | var start: DevicePin 5 | var end: DevicePin 6 | 7 | public static var count = 0 8 | public var id: String 9 | 10 | public var points: [CGPoint] = [] 11 | public var lineNode: SKShapeNode = SKShapeNode() 12 | 13 | public var state: Bool { start.device.outputs[start.terminal] } 14 | 15 | 16 | public init(from start: DevicePin, 17 | to end: DevicePin, 18 | points: [CGPoint]) { 19 | self.start = start 20 | self.end = end 21 | self.points = points 22 | 23 | Connection.count += 1 24 | id = "CONNECTION" + String(Connection.count) 25 | 26 | lineNode.path = Self.makePath(points: points) 27 | lineNode.strokeColor = SKColor.darkGray 28 | lineNode.lineWidth = 8 29 | lineNode.lineJoin = .round 30 | lineNode.lineCap = .round 31 | lineNode.zPosition = -10000 32 | lineNode.name = "CONNECTION" 33 | lineNode.userData = NSMutableDictionary() 34 | lineNode.userData?.setValue(self, forKey: "connection") 35 | } 36 | 37 | public static func == (lhs: Connection, rhs: Connection) -> Bool { 38 | return lhs.id == rhs.id 39 | } 40 | 41 | public static func makePath(points: [CGPoint]) -> CGMutablePath { 42 | let path = CGMutablePath() 43 | points.enumerated().forEach { (i, point) in 44 | i == 0 ? path.move(to: point) : path.addLine(to: point) 45 | } 46 | return path 47 | } 48 | 49 | public func updateStartPoint(to point: CGPoint) { 50 | points[0] = point 51 | let path = Self.makePath(points: points) 52 | lineNode.path = path 53 | } 54 | 55 | public func updateEndPoint(to point: CGPoint) { 56 | let lastIndex = points.count - 1 57 | points[lastIndex] = point 58 | let path = Self.makePath(points: points) 59 | lineNode.path = path 60 | } 61 | 62 | public func updateColor() { 63 | lineNode.strokeColorTransition(to: state ? .canvasGreen : .canvasYellow, duration: 1/60) 64 | } 65 | 66 | public func resetColor() { 67 | lineNode.strokeColorTransition(to: .darkGray) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Device+CanvasScene.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension Device { 4 | func updateWire() { 5 | wireNodes.forEach { wireNode in 6 | guard let terminal = wireNode.terminal else { 7 | return 8 | } 9 | if wireNode.getPinType() == .input { 10 | guard let incomingConnection = incomingConnections[terminal], 11 | let incomingConnection = incomingConnection else { 12 | return 13 | } 14 | wireNode.strokeColorTransition(to: incomingConnection.state ? .canvasGreen : .canvasYellow, duration: 1/60) 15 | } else { 16 | wireNode.strokeColorTransition(to: outputs[terminal] ? .canvasGreen : .canvasYellow, duration: 1/60) 17 | } 18 | } 19 | } 20 | 21 | func resetWire() { 22 | wireNodes.forEach { wireNode in 23 | wireNode.strokeColorTransition(to: .darkGray) 24 | 25 | } 26 | } 27 | 28 | static func tagPath() -> CGMutablePath { 29 | let path = CGMutablePath() 30 | path.move(to: CGPoint(x: -12, y: -8)) 31 | path.addLine(to: CGPoint(x: 0, y: -8)) 32 | path.addLine(to: CGPoint(x: 12, y: 0)) 33 | path.addLine(to: CGPoint(x: 0, y: 8)) 34 | path.addLine(to: CGPoint(x: -12, y: 8)) 35 | path.addLine(to: CGPoint(x: -12, y: -8)) 36 | return path 37 | } 38 | 39 | static func twoInputGateSprite(wires: inout [SKShapeNode], pins: inout [SKSpriteNode]) -> SKSpriteNode { 40 | let spriteNode = SKSpriteNode() 41 | 42 | let wireNode1 = SKShapeNode() 43 | let wirePath1 = CGMutablePath() 44 | wirePath1.move(to: CGPoint(x: -128, y: 64)) 45 | wirePath1.addLine(to: CGPoint(x: 0, y: 64)) 46 | wireNode1.path = wirePath1 47 | wireNode1.strokeColor = SKColor.darkGray 48 | wireNode1.lineWidth = 8 49 | wireNode1.name = "WIRE1" 50 | wireNode1.userData = NSMutableDictionary() 51 | wireNode1.userData?.setValue(PinType.input, forKey: "type") 52 | wireNode1.userData?.setValue(Int(0), forKey: "terminal") 53 | wires.append(wireNode1) 54 | spriteNode.addChild(wireNode1) 55 | 56 | let wireNode2 = SKShapeNode() 57 | let wirePath2 = CGMutablePath() 58 | wirePath2.move(to: CGPoint(x: -128, y: -64)) 59 | wirePath2.addLine(to: CGPoint(x: 0, y: -64)) 60 | wireNode2.path = wirePath2 61 | wireNode2.strokeColor = SKColor.darkGray 62 | wireNode2.lineWidth = 8 63 | wireNode2.name = "WIRE2" 64 | wireNode2.userData = NSMutableDictionary() 65 | wireNode2.userData?.setValue(PinType.input, forKey: "type") 66 | wireNode2.userData?.setValue(Int(1), forKey: "terminal") 67 | wires.append(wireNode2) 68 | spriteNode.addChild(wireNode2) 69 | 70 | let wireNode3 = SKShapeNode() 71 | let wirePath3 = CGMutablePath() 72 | wirePath3.move(to: CGPoint(x: 0, y: 0)) 73 | wirePath3.addLine(to: CGPoint(x: 128, y: 0)) 74 | wireNode3.path = wirePath3 75 | wireNode3.strokeColor = SKColor.darkGray 76 | wireNode3.lineWidth = 8 77 | wireNode3.name = "WIRE3" 78 | wireNode3.userData = NSMutableDictionary() 79 | wireNode3.userData?.setValue(PinType.output, forKey: "type") 80 | wireNode3.userData?.setValue(Int(0), forKey: "terminal") 81 | wires.append(wireNode3) 82 | spriteNode.addChild(wireNode3) 83 | 84 | let pinNode1 = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 85 | pinNode1.position = .init(x: -128, y: 64) 86 | pinNode1.name = "PIN1" 87 | pinNode1.userData = NSMutableDictionary() 88 | pinNode1.userData?.setValue(PinType.input, forKey: "type") 89 | pinNode1.userData?.setValue(Int(0), forKey: "terminal") 90 | pins.append(pinNode1) 91 | spriteNode.addChild(pinNode1) 92 | 93 | let pinShapeNode1 = SKShapeNode(rect: .init(x: -8, y: -8, width: 16, height: 16), cornerRadius: 4) 94 | pinShapeNode1.fillColor = SKColor.gray 95 | pinShapeNode1.strokeColor = SKColor.darkGray 96 | pinShapeNode1.lineWidth = 4 97 | pinShapeNode1.lineJoin = .round 98 | pinNode1.addChild(pinShapeNode1) 99 | 100 | let pinNode2 = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 101 | pinNode2.position = .init(x: -128, y: -64) 102 | pinNode2.name = "PIN2" 103 | pinNode2.userData = NSMutableDictionary() 104 | pinNode2.userData?.setValue(PinType.input, forKey: "type") 105 | pinNode2.userData?.setValue(Int(1), forKey: "terminal") 106 | pins.append(pinNode2) 107 | spriteNode.addChild(pinNode2) 108 | 109 | let pinShapeNode2 = SKShapeNode(rect: .init(x: -8, y: -8, width: 16, height: 16), cornerRadius: 4) 110 | pinShapeNode2.fillColor = SKColor.gray 111 | pinShapeNode2.strokeColor = SKColor.darkGray 112 | pinShapeNode2.lineWidth = 4 113 | pinShapeNode2.lineJoin = .round 114 | pinNode2.addChild(pinShapeNode2) 115 | 116 | let pinNode3 = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 117 | pinNode3.position = .init(x: 128, y: 0) 118 | pinNode3.name = "PIN3" 119 | pinNode3.userData = NSMutableDictionary() 120 | pinNode3.userData?.setValue(PinType.output, forKey: "type") 121 | pinNode3.userData?.setValue(false, forKey: "occupied") 122 | pinNode3.userData?.setValue(Int(0), forKey: "terminal") 123 | pins.append(pinNode3) 124 | spriteNode.addChild(pinNode3) 125 | 126 | let pinShapeNode3 = SKShapeNode(rect: .init(x: -8, y: -8, width: 16, height: 16), cornerRadius: 4) 127 | pinShapeNode3.fillColor = SKColor.gray 128 | pinShapeNode3.strokeColor = SKColor.darkGray 129 | pinShapeNode3.lineWidth = 4 130 | pinShapeNode3.lineJoin = .round 131 | pinNode3.addChild(pinShapeNode3) 132 | 133 | return spriteNode 134 | } 135 | } 136 | 137 | extension SKNode { 138 | var terminal: Int? { 139 | guard let terminal = self.userData?.value(forKey: "terminal") as? Int else { return nil } 140 | return terminal 141 | } 142 | 143 | var parentDevice: (any Device)? { 144 | guard let parentDevice = self.userData?.value(forKey: "parentDevice") as? any Device else { return nil } 145 | return parentDevice 146 | } 147 | 148 | var parentConnection: Connection? { 149 | guard let connection = self.userData?.value(forKey: "connection") as? Connection else { return nil } 150 | return connection 151 | } 152 | 153 | func getPinType() -> PinType? { 154 | self.userData?.value(forKey: "type") as? PinType 155 | } 156 | 157 | func isOccupied() -> Bool { 158 | guard let occupied = self.userData?.value(forKey: "occupied") as? Bool else { return false } 159 | return occupied 160 | } 161 | 162 | func occupied(_ isOcuppied: Bool) { 163 | self.userData?.setValue(isOcuppied, forKey: "occupied") 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Device.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public protocol Device: Identifiable { 4 | var parentCircuit: Circuit? { get set } 5 | 6 | var inputs: [Bool] { get set } 7 | var outputs: [Bool] { get set } 8 | 9 | var deviceName: String { get } 10 | var imageName: String { get } 11 | 12 | static var count: Int { get set } 13 | var id: String { get set } 14 | 15 | var incomingConnections: [Int: Connection?] { get set } 16 | var outcomingConnections: [Int: [Connection]] { get set } 17 | 18 | var visited: Bool { get set } 19 | var level: Int? { get set } 20 | 21 | var spriteNode: SKSpriteNode { get set } 22 | var imageNode: SKSpriteNode { get set } 23 | var wireNodes: [SKShapeNode] { get set } 24 | var pinNodes: [SKSpriteNode] { get set } 25 | 26 | func run() 27 | } 28 | 29 | extension Device { 30 | var inputCount: Int { inputs.count } 31 | var outputCount: Int { outputs.count } 32 | 33 | static func ==(lhs: any Device, rhs: any Device) -> Bool { 34 | return lhs.id == rhs.id 35 | } 36 | 37 | mutating func assignParentCircuit(_ parent: Circuit) { 38 | self.parentCircuit = parent 39 | } 40 | 41 | mutating func resetVisited() { 42 | visited = false 43 | } 44 | } 45 | 46 | protocol Input: Device { } 47 | 48 | protocol Output: Device { } 49 | 50 | public protocol Gate: Device { } 51 | 52 | public protocol Expandable: Gate { } 53 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/DeviceName.swift: -------------------------------------------------------------------------------- 1 | public enum DeviceName: String, Identifiable, Codable { 2 | case AND, NAND 3 | case OR, NOR 4 | case XOR, XNOR 5 | case NOT, BUFFER 6 | case PUSHBUTTON 7 | case DISPLAY 8 | 9 | public var id: String { deviceName } 10 | 11 | public var imageName: String { 12 | switch self { 13 | case .AND: 14 | return "AND" 15 | case .NAND: 16 | return "NAND" 17 | case .OR: 18 | return "OR" 19 | case .NOR: 20 | return "NOR" 21 | case .XOR: 22 | return "XOR" 23 | case .XNOR: 24 | return "XNOR" 25 | case .NOT: 26 | return "NOT" 27 | case .BUFFER: 28 | return "BUFFER" 29 | case .PUSHBUTTON: 30 | return "InputFalse" 31 | case .DISPLAY: 32 | return "OutputFalse" 33 | } 34 | } 35 | 36 | public var deviceName: String { 37 | switch self { 38 | case .AND: 39 | return "AND GATE" 40 | case .NAND: 41 | return "NAND GATE" 42 | case .OR: 43 | return "OR GATE" 44 | case .NOR: 45 | return "NOR GATE" 46 | case .XOR: 47 | return "XOR GATE" 48 | case .XNOR: 49 | return "XNOR GATE" 50 | case .NOT: 51 | return "NOT GATE" 52 | case .BUFFER: 53 | return "BUFFER" 54 | case .PUSHBUTTON: 55 | return "BUTTON" 56 | case .DISPLAY: 57 | return "DISPLAY" 58 | } 59 | } 60 | 61 | public static func deviceStringToDeviceName(string: String) -> DeviceName { 62 | switch string { 63 | case "AND GATE": 64 | return .AND 65 | case "NAND GATE": 66 | return .NAND 67 | case "OR GATE": 68 | return .OR 69 | case "NOR GATE": 70 | return .NOR 71 | case "XOR GATE": 72 | return .XOR 73 | case "XNOR GATE": 74 | return .XNOR 75 | case "NOT GATE": 76 | return .NOT 77 | case "BUFFER": 78 | return .BUFFER 79 | case "DISPLAY": 80 | return .DISPLAY 81 | default: 82 | return .PUSHBUTTON 83 | } 84 | } 85 | 86 | public func deviceNameToDevice() -> any Device { 87 | switch self { 88 | case .AND: 89 | return And() 90 | case .NAND: 91 | return Nand() 92 | case .OR: 93 | return Or() 94 | case .NOR: 95 | return Nor() 96 | case .XOR: 97 | return Xor() 98 | case .XNOR: 99 | return Xnor() 100 | case .NOT: 101 | return Not() 102 | case .BUFFER: 103 | return Buffer() 104 | case .DISPLAY: 105 | return Display() 106 | case .PUSHBUTTON: 107 | return PushButton() 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/DevicePin.swift: -------------------------------------------------------------------------------- 1 | public typealias DevicePin = (device: any Device, terminal: Int) 2 | 3 | public enum PinType { 4 | case input, output 5 | } 6 | 7 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/And.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class And: Gate, Expandable { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "AND GATE" 7 | public var imageName: String = "AND" 8 | 9 | public var inputs: [Bool] = [false, false] 10 | public var outputs: [Bool] = [false] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil, 13 | 1: nil] 14 | public var outcomingConnections: [Int: [Connection]] = [0: []] 15 | 16 | public static var count = 0 17 | public var id: String 18 | 19 | public var visited: Bool = false 20 | public var level: Int? 21 | 22 | public var spriteNode: SKSpriteNode 23 | public var imageNode: SKSpriteNode 24 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 25 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 26 | 27 | public init() { 28 | Self.count += 1 29 | id = "AND" + String(Self.count) 30 | 31 | spriteNode = Self.twoInputGateSprite(wires: &wireNodes, pins: &pinNodes) 32 | 33 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 34 | imageNode.size = CGSize(width: 256, height: 256) 35 | imageNode.position = .init(x: 8, y: 0) 36 | imageNode.name = "MAIN-" + id 37 | 38 | spriteNode.addChild(imageNode) 39 | spriteNode.userData = NSMutableDictionary() 40 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 41 | } 42 | 43 | public func run() { 44 | outputs[0] = inputs.allSatisfy({$0}) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/Buffer.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Buffer: Gate { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "BUFFER" 7 | public var imageName: String = "BUFFER" 8 | 9 | public var inputs: [Bool] = [false] 10 | public var outputs: [Bool] = [true] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil] 13 | public var outcomingConnections: [Int: [Connection]] = [0: []] 14 | 15 | public static var count = 0 16 | public var id: String 17 | 18 | public var visited: Bool = false 19 | public var level: Int? 20 | 21 | public var spriteNode: SKSpriteNode = SKSpriteNode() 22 | public var imageNode: SKSpriteNode 23 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 24 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 25 | 26 | public init() { 27 | Self.count += 1 28 | id = "BUFFER" + String(Self.count) 29 | 30 | let wireNode1 = SKShapeNode() 31 | let wirePath1 = CGMutablePath() 32 | wirePath1.move(to: CGPoint(x: -64, y: 0)) 33 | wirePath1.addLine(to: CGPoint(x: 0, y: 0)) 34 | wireNode1.path = wirePath1 35 | wireNode1.strokeColor = SKColor.darkGray 36 | wireNode1.lineWidth = 8 37 | wireNode1.name = "WIRE1" 38 | wireNode1.userData = NSMutableDictionary() 39 | wireNode1.userData?.setValue(PinType.input, forKey: "type") 40 | wireNode1.userData?.setValue(Int(0), forKey: "terminal") 41 | 42 | wireNodes.append(wireNode1) 43 | spriteNode.addChild(wireNode1) 44 | 45 | let wireNode2 = SKShapeNode() 46 | let wirePath2 = CGMutablePath() 47 | wirePath2.move(to: CGPoint(x: 0, y: 0)) 48 | wirePath2.addLine(to: CGPoint(x: 64, y: 0)) 49 | wireNode2.path = wirePath2 50 | wireNode2.strokeColor = SKColor.darkGray 51 | wireNode2.lineWidth = 8 52 | wireNode2.name = "WIRE2" 53 | wireNode2.userData = NSMutableDictionary() 54 | wireNode2.userData?.setValue(PinType.output, forKey: "type") 55 | wireNode2.userData?.setValue(Int(0), forKey: "terminal") 56 | 57 | wireNodes.append(wireNode2) 58 | spriteNode.addChild(wireNode2) 59 | 60 | let pinNode1 = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 61 | pinNode1.position = .init(x: -64, y: 0) 62 | pinNode1.name = "PIN1" 63 | pinNode1.userData = NSMutableDictionary() 64 | pinNode1.userData?.setValue(PinType.input, forKey: "type") 65 | pinNode1.userData?.setValue(Int(0), forKey: "terminal") 66 | pinNodes.append(pinNode1) 67 | spriteNode.addChild(pinNode1) 68 | 69 | let pinShapeNode1 = SKShapeNode(rect: .init(origin: .init(x: -8, y: -8), size: .init(width: 16, height: 16)), cornerRadius: 4) 70 | pinShapeNode1.fillColor = SKColor.gray 71 | pinShapeNode1.strokeColor = SKColor.darkGray 72 | pinShapeNode1.lineWidth = 4 73 | pinNode1.addChild(pinShapeNode1) 74 | 75 | let pinNode2 = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 76 | pinNode2.position = .init(x: 64, y: 0) 77 | pinNode2.name = "PIN2" 78 | pinNode2.userData = NSMutableDictionary() 79 | pinNode2.userData?.setValue(PinType.output, forKey: "type") 80 | pinNode2.userData?.setValue(false, forKey: "occupied") 81 | pinNode2.userData?.setValue(Int(0), forKey: "terminal") 82 | pinNodes.append(pinNode2) 83 | spriteNode.addChild(pinNode2) 84 | 85 | let pinShapeNode2 = SKShapeNode(rect: .init(origin: .init(x: -8, y: -8), size: .init(width: 16, height: 16)), cornerRadius: 4) 86 | pinShapeNode2.fillColor = SKColor.gray 87 | pinShapeNode2.strokeColor = SKColor.darkGray 88 | pinShapeNode2.lineWidth = 4 89 | pinShapeNode2.lineJoin = .round 90 | pinNode2.addChild(pinShapeNode2) 91 | 92 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 93 | imageNode.size = CGSize(width: 128, height: 128) 94 | imageNode.position = .init(x: 8, y: 0) 95 | imageNode.name = "MAIN-" + id 96 | 97 | spriteNode.addChild(imageNode) 98 | spriteNode.userData = NSMutableDictionary() 99 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 100 | } 101 | 102 | public func run() { 103 | outputs[0] = inputs[0] 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/Nand.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Nand: Gate, Expandable { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "NAND GATE" 7 | public var imageName: String = "NAND" 8 | 9 | public var inputs: [Bool] = [false, false] 10 | public var outputs: [Bool] = [true] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil, 13 | 1: nil] 14 | public var outcomingConnections: [Int: [Connection]] = [0: []] 15 | 16 | public static var count = 0 17 | public var id: String 18 | 19 | public var visited: Bool = false 20 | public var level: Int? 21 | 22 | public var spriteNode: SKSpriteNode = SKSpriteNode() 23 | public var imageNode: SKSpriteNode 24 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 25 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 26 | 27 | public init() { 28 | Self.count += 1 29 | id = "NAND" + String(Self.count) 30 | 31 | spriteNode = Self.twoInputGateSprite(wires: &wireNodes, pins: &pinNodes) 32 | 33 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 34 | imageNode.size = CGSize(width: 256, height: 256) 35 | imageNode.name = "MAIN-" + id 36 | 37 | spriteNode.addChild(imageNode) 38 | spriteNode.userData = NSMutableDictionary() 39 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 40 | } 41 | 42 | public func run() { 43 | outputs[0] = inputs.contains(false) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/Nor.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Nor: Gate, Expandable { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "NOR GATE" 7 | public var imageName: String = "NOR" 8 | 9 | public var inputs: [Bool] = [false, false] 10 | public var outputs: [Bool] = [true] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil, 13 | 1: nil] 14 | public var outcomingConnections: [Int: [Connection]] = [0: []] 15 | 16 | public static var count = 0 17 | public var id: String 18 | 19 | public var visited: Bool = false 20 | public var level: Int? 21 | 22 | public var spriteNode: SKSpriteNode = SKSpriteNode() 23 | public var imageNode: SKSpriteNode 24 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 25 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 26 | 27 | init() { 28 | Self.count += 1 29 | id = "NOR" + String(Self.count) 30 | 31 | spriteNode = Self.twoInputGateSprite(wires: &wireNodes, pins: &pinNodes) 32 | 33 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 34 | imageNode.size = CGSize(width: 256, height: 256) 35 | imageNode.name = "MAIN-" + id 36 | 37 | spriteNode.addChild(imageNode) 38 | spriteNode.userData = NSMutableDictionary() 39 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 40 | } 41 | 42 | public func run() { 43 | outputs[0] = inputs.allSatisfy({$0 == false}) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/Not.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Not: Gate { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "NOT GATE" 7 | public var imageName: String = "NOT" 8 | 9 | public var inputs: [Bool] = [false] 10 | public var outputs: [Bool] = [true] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil] 13 | public var outcomingConnections: [Int: [Connection]] = [0: []] 14 | 15 | public static var count = 0 16 | public var id: String 17 | 18 | public var visited: Bool = false 19 | public var level: Int? 20 | 21 | public var spriteNode: SKSpriteNode = SKSpriteNode() 22 | public var imageNode: SKSpriteNode 23 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 24 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 25 | 26 | public init() { 27 | Self.count += 1 28 | id = "NOT" + String(Self.count) 29 | 30 | let wireNode1 = SKShapeNode() 31 | let wirePath1 = CGMutablePath() 32 | wirePath1.move(to: CGPoint(x: -64, y: 0)) 33 | wirePath1.addLine(to: CGPoint(x: 0, y: 0)) 34 | wireNode1.path = wirePath1 35 | wireNode1.strokeColor = SKColor.darkGray 36 | wireNode1.lineWidth = 8 37 | wireNode1.name = "WIRE1" 38 | wireNode1.userData = NSMutableDictionary() 39 | wireNode1.userData?.setValue(PinType.input, forKey: "type") 40 | wireNode1.userData?.setValue(Int(0), forKey: "terminal") 41 | 42 | wireNodes.append(wireNode1) 43 | spriteNode.addChild(wireNode1) 44 | 45 | let wireNode2 = SKShapeNode() 46 | let wirePath2 = CGMutablePath() 47 | wirePath2.move(to: CGPoint(x: 0, y: 0)) 48 | wirePath2.addLine(to: CGPoint(x: 64, y: 0)) 49 | wireNode2.path = wirePath2 50 | wireNode2.strokeColor = SKColor.darkGray 51 | wireNode2.lineWidth = 8 52 | wireNode2.name = "WIRE2" 53 | wireNode2.userData = NSMutableDictionary() 54 | wireNode2.userData?.setValue(PinType.output, forKey: "type") 55 | wireNode2.userData?.setValue(Int(0), forKey: "terminal") 56 | 57 | wireNodes.append(wireNode2) 58 | spriteNode.addChild(wireNode2) 59 | 60 | let pinNode1 = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 61 | pinNode1.position = .init(x: -64, y: 0) 62 | pinNode1.name = "PIN1" 63 | pinNode1.userData = NSMutableDictionary() 64 | pinNode1.userData?.setValue(PinType.input, forKey: "type") 65 | pinNode1.userData?.setValue(Int(0), forKey: "terminal") 66 | pinNodes.append(pinNode1) 67 | spriteNode.addChild(pinNode1) 68 | 69 | let pinShapeNode1 = SKShapeNode(rect: .init(origin: .init(x: -8, y: -8), size: .init(width: 16, height: 16)), cornerRadius: 4) 70 | pinShapeNode1.fillColor = SKColor.gray 71 | pinShapeNode1.strokeColor = SKColor.darkGray 72 | pinShapeNode1.lineWidth = 4 73 | pinNode1.addChild(pinShapeNode1) 74 | 75 | let pinNode2 = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 76 | pinNode2.position = .init(x: 64, y: 0) 77 | pinNode2.name = "PIN2" 78 | pinNode2.userData = NSMutableDictionary() 79 | pinNode2.userData?.setValue(PinType.output, forKey: "type") 80 | pinNode2.userData?.setValue(false, forKey: "occupied") 81 | pinNode2.userData?.setValue(Int(0), forKey: "terminal") 82 | pinNodes.append(pinNode2) 83 | spriteNode.addChild(pinNode2) 84 | 85 | let pinShapeNode2 = SKShapeNode(rect: .init(origin: .init(x: -8, y: -8), size: .init(width: 16, height: 16)), cornerRadius: 4) 86 | pinShapeNode2.fillColor = SKColor.gray 87 | pinShapeNode2.strokeColor = SKColor.darkGray 88 | pinShapeNode2.lineWidth = 4 89 | pinShapeNode2.lineJoin = .round 90 | pinNode2.addChild(pinShapeNode2) 91 | 92 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 93 | imageNode.size = CGSize(width: 128, height: 128) 94 | imageNode.name = "MAIN-" + id 95 | 96 | spriteNode.addChild(imageNode) 97 | spriteNode.userData = NSMutableDictionary() 98 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 99 | } 100 | 101 | public func run() { 102 | outputs[0] = !inputs[0] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/Or.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Or: Gate, Expandable { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "OR GATE" 7 | public var imageName: String = "OR" 8 | 9 | public var inputs: [Bool] = [false, false] 10 | public var outputs: [Bool] = [false] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil, 13 | 1: nil] 14 | public var outcomingConnections: [Int: [Connection]] = [0: []] 15 | 16 | public static var count = 0 17 | public var id: String 18 | 19 | public var visited: Bool = false 20 | public var level: Int? 21 | 22 | public var spriteNode: SKSpriteNode = SKSpriteNode() 23 | public var imageNode: SKSpriteNode 24 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 25 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 26 | 27 | public init() { 28 | Self.count += 1 29 | id = "OR" + String(Self.count) 30 | 31 | spriteNode = Self.twoInputGateSprite(wires: &wireNodes, pins: &pinNodes) 32 | 33 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 34 | imageNode.size = CGSize(width: 256, height: 256) 35 | imageNode.position = .init(x: 4, y: 0) 36 | imageNode.name = "MAIN-" + id 37 | 38 | spriteNode.addChild(imageNode) 39 | spriteNode.userData = NSMutableDictionary() 40 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 41 | } 42 | 43 | public func run() { 44 | outputs[0] = inputs.contains(true) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/Xnor.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Xnor: Gate, Expandable { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "XNOR GATE" 7 | public var imageName: String = "XNOR" 8 | 9 | public var inputs: [Bool] = [false, false] 10 | public var outputs: [Bool] = [true] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil, 13 | 1: nil] 14 | public var outcomingConnections: [Int: [Connection]] = [0: []] 15 | 16 | public static var count = 0 17 | public var id: String 18 | 19 | public var visited: Bool = false 20 | public var level: Int? 21 | 22 | public var spriteNode: SKSpriteNode = SKSpriteNode() 23 | public var imageNode: SKSpriteNode 24 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 25 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 26 | 27 | public init() { 28 | Self.count += 1 29 | id = "XNOR" + String(Self.count) 30 | 31 | spriteNode = Self.twoInputGateSprite(wires: &wireNodes, pins: &pinNodes) 32 | 33 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 34 | imageNode.size = CGSize(width: 256, height: 256) 35 | imageNode.name = "MAIN-" + id 36 | 37 | spriteNode.addChild(imageNode) 38 | spriteNode.userData = NSMutableDictionary() 39 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 40 | } 41 | 42 | 43 | public func run() { 44 | outputs[0] = inputs.filter({$0}).count % 2 == 0 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Gate/Xor.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Xor: Gate, Expandable { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "XOR GATE" 7 | public var imageName: String = "XOR" 8 | 9 | public var inputs: [Bool] = [false, false] 10 | public var outputs: [Bool] = [false] 11 | 12 | public var incomingConnections: [Int: Connection?] = [0: nil, 13 | 1: nil] 14 | public var outcomingConnections: [Int: [Connection]] = [0: []] 15 | 16 | public static var count = 0 17 | public var id: String 18 | 19 | public var visited: Bool = false 20 | public var level: Int? 21 | 22 | public var spriteNode: SKSpriteNode = SKSpriteNode() 23 | public var imageNode: SKSpriteNode 24 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 25 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 26 | 27 | public init() { 28 | Self.count += 1 29 | id = "XOR" + String(Self.count) 30 | 31 | spriteNode = Self.twoInputGateSprite(wires: &wireNodes, pins: &pinNodes) 32 | 33 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 34 | imageNode.size = CGSize(width: 256, height: 256) 35 | imageNode.position = .init(x: 4, y: 0) 36 | imageNode.name = "MAIN-" + id 37 | 38 | spriteNode.addChild(imageNode) 39 | spriteNode.userData = NSMutableDictionary() 40 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 41 | } 42 | 43 | public func run() { 44 | outputs[0] = inputs.filter({$0}).count % 2 == 1 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Input/PushButton.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class PushButton: Input { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "PUSHBUTTON" 7 | public var imageName: String = "InputFalse" 8 | 9 | public var inputs: [Bool] = [] 10 | public var outputs: [Bool] = [false] { 11 | didSet { 12 | if oldValue[0] != outputs[0] { 13 | imageNode.texture = outputs[0] ? .onButtonTexture : .offButtonTexture 14 | } 15 | } 16 | } 17 | 18 | public var state: Bool { outputs[0] } 19 | 20 | public var incomingConnections: [Int: Connection?] = [Int: Connection?]() 21 | public var outcomingConnections: [Int: [Connection]] = [0: []] 22 | 23 | public static var count = 0 24 | public var id: String 25 | 26 | public var visited: Bool = false 27 | public var level: Int? 28 | 29 | public var spriteNode: SKSpriteNode = SKSpriteNode() 30 | public var imageNode: SKSpriteNode 31 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 32 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 33 | 34 | public init() { 35 | Self.count += 1 36 | id = "IN" + String(Self.count) 37 | 38 | let wireNode = SKShapeNode() 39 | let wirePath = CGMutablePath() 40 | wirePath.move(to: CGPoint(x: 0, y: 0)) 41 | wirePath.addLine(to: CGPoint(x: 64, y: 0)) 42 | wireNode.path = wirePath 43 | wireNode.strokeColor = SKColor.darkGray 44 | wireNode.userData = NSMutableDictionary() 45 | wireNode.userData?.setValue(PinType.output, forKey: "type") 46 | wireNode.userData?.setValue(Int(0), forKey: "terminal") 47 | wireNode.lineWidth = 8 48 | wireNode.name = "WIRE1" 49 | 50 | wireNodes.append(wireNode) 51 | spriteNode.addChild(wireNode) 52 | 53 | let pinNode = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 54 | pinNode.position = .init(x: 64, y: 0) 55 | pinNode.name = "PIN1" 56 | pinNode.userData = NSMutableDictionary() 57 | pinNode.userData?.setValue(PinType.output, forKey: "type") 58 | pinNode.userData?.setValue(Int(0), forKey: "terminal") 59 | pinNodes.append(pinNode) 60 | spriteNode.addChild(pinNode) 61 | 62 | let pinShapeNode = SKShapeNode(rect: .init(origin: .init(x: -8, y: -8), size: .init(width: 16, height: 16)), cornerRadius: 4) 63 | pinShapeNode.fillColor = SKColor.gray 64 | pinShapeNode.name = "SHAPE" 65 | pinShapeNode.strokeColor = SKColor.darkGray 66 | pinShapeNode.lineWidth = 4 67 | pinShapeNode.lineJoin = .round 68 | pinNode.addChild(pinShapeNode) 69 | 70 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 71 | imageNode.size = CGSize(width: 128, height: 128) 72 | imageNode.name = "MAIN-" + id 73 | 74 | spriteNode.addChild(imageNode) 75 | spriteNode.userData = NSMutableDictionary() 76 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 77 | } 78 | 79 | public func run() { 80 | } 81 | 82 | public func toggle() { 83 | outputs[0] = !outputs[0] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Components/Device/Output/Display.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | public class Display: Output { 4 | public var parentCircuit: Circuit? 5 | 6 | public var deviceName: String = "DISPLAY" 7 | public var imageName: String = "OutputFalse" 8 | 9 | public var inputs: [Bool] = [false] 10 | public var outputs: [Bool] = [] 11 | public var state: Bool { inputs[0] } 12 | 13 | public var incomingConnections: [Int: Connection?] = [0: nil] 14 | public var outcomingConnections: [Int: [Connection]] = [Int: [Connection]]() 15 | 16 | public static var count = 0 17 | public var id: String 18 | 19 | public var visited: Bool = false 20 | public var level: Int? 21 | 22 | public var spriteNode: SKSpriteNode = SKSpriteNode() 23 | public var imageNode: SKSpriteNode 24 | public var wireNodes: [SKShapeNode] = [SKShapeNode]() 25 | public var pinNodes: [SKSpriteNode] = [SKSpriteNode]() 26 | 27 | public init() { 28 | Self.count += 1 29 | id = "OUT" + String(Self.count) 30 | 31 | let wireNode = SKShapeNode() 32 | let wirePath = CGMutablePath() 33 | wirePath.move(to: CGPoint(x: -64, y: 0)) 34 | wirePath.addLine(to: CGPoint(x: 0, y: 0)) 35 | wireNode.path = wirePath 36 | wireNode.strokeColor = SKColor.darkGray 37 | wireNode.lineWidth = 8 38 | wireNode.name = "WIRE1" 39 | wireNode.userData = NSMutableDictionary() 40 | wireNode.userData?.setValue(PinType.input, forKey: "type") 41 | wireNode.userData?.setValue(Int(0), forKey: "terminal") 42 | wireNodes.append(wireNode) 43 | spriteNode.addChild(wireNode) 44 | 45 | let pinNode = SKSpriteNode(color: .clear, size: .init(width: 36, height: 36)) 46 | pinNode.position = .init(x: -64, y: 0) 47 | pinNode.name = "PIN1" 48 | pinNode.userData = NSMutableDictionary() 49 | pinNode.userData?.setValue(PinType.input, forKey: "type") 50 | pinNode.userData?.setValue(false, forKey: "occupied") 51 | pinNode.userData?.setValue(Int(0), forKey: "terminal") 52 | pinNodes.append(pinNode) 53 | spriteNode.addChild(pinNode) 54 | 55 | let pinShapeNode = SKShapeNode(rect: .init(origin: .init(x: -8, y: -8), size: .init(width: 16, height: 16)), cornerRadius: 4) 56 | pinShapeNode.fillColor = SKColor.gray 57 | pinShapeNode.strokeColor = SKColor.darkGray 58 | pinShapeNode.lineWidth = 4 59 | pinNode.addChild(pinShapeNode) 60 | 61 | imageNode = SKSpriteNode(imageNamed: imageName + ".png") 62 | imageNode.size = CGSize(width: 128, height: 128) 63 | imageNode.name = "MAIN-" + id 64 | 65 | spriteNode.addChild(imageNode) 66 | spriteNode.userData = NSMutableDictionary() 67 | spriteNode.userData?.setValue(self, forKey: "parentDevice") 68 | } 69 | 70 | public func run() { 71 | imageNode.texture = inputs[0] ? .onDisplayTexture : .offDisplayTexture 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/Extensions/Nameable.swift: -------------------------------------------------------------------------------- 1 | protocol Nameable { 2 | var name: String { get set } 3 | } 4 | 5 | extension Nameable { 6 | mutating func changeName(to newName: String) { 7 | name = newName 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/LogicBoard.swift: -------------------------------------------------------------------------------- 1 | public struct LogicBoard { 2 | public var mainCircuit: Circuit 3 | public var circuits: [Circuit] = [Circuit]() 4 | public var selectedCircuit: Circuit 5 | public var state: BoardState = .file { 6 | didSet { 7 | circuits.forEach { circuit in 8 | circuit.state = state 9 | } 10 | } 11 | } 12 | 13 | public init() { 14 | mainCircuit = Circuit.fullAdder() 15 | mainCircuit.isExample = true 16 | circuits.append(mainCircuit) 17 | 18 | let dLatch = Circuit.dLatch() 19 | dLatch.isExample = true 20 | circuits.append(dLatch) 21 | selectedCircuit = mainCircuit 22 | } 23 | 24 | public mutating func addCircuit() { 25 | circuits.append(Circuit()) 26 | } 27 | 28 | public mutating func addCircuit(_ circuit: Circuit) { 29 | circuits.append(circuit) 30 | } 31 | 32 | public mutating func removeCircuit(_ circuit: Circuit) { 33 | guard let index = circuits.firstIndex(of: circuit) else { 34 | return 35 | } 36 | circuits.remove(at: index) 37 | } 38 | } 39 | 40 | extension LogicBoard { 41 | subscript(index: Int) -> Circuit { 42 | get { 43 | self.circuits[index] 44 | } 45 | set { 46 | self.circuits[index] = newValue 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/LogicBoard/LogicEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class LogicEnvironment: ObservableObject { 4 | @Published var board: LogicBoard 5 | @Published var showDock: Bool = true 6 | 7 | @Published var selectedCircuit: Circuit { 8 | willSet { 9 | board.selectedCircuit = newValue 10 | } 11 | } 12 | 13 | init() { 14 | let board = LogicBoard() 15 | self.board = board 16 | self.selectedCircuit = board.selectedCircuit 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | 3 | // WARNING: 4 | // This file is automatically generated. 5 | // Do not edit it by hand because the contents will be replaced. 6 | 7 | import PackageDescription 8 | import AppleProductTypes 9 | 10 | let package = Package( 11 | name: "LogicBoard", 12 | platforms: [ 13 | .iOS("16.0") 14 | ], 15 | products: [ 16 | .iOSApplication( 17 | name: "LogicBoard", 18 | targets: ["AppModule"], 19 | teamIdentifier: "6SK4Y4DTME", 20 | displayVersion: "1.0", 21 | bundleVersion: "1", 22 | appIcon: .asset("AppIcon"), 23 | accentColor: .presetColor(.brown), 24 | supportedDeviceFamilies: [ 25 | .pad, 26 | .phone 27 | ], 28 | supportedInterfaceOrientations: [ 29 | .portrait, 30 | .landscapeRight, 31 | .landscapeLeft, 32 | .portraitUpsideDown(.when(deviceFamilies: [.pad])) 33 | ], 34 | capabilities: [ 35 | .fileAccess(.downloadsFolder, mode: .readWrite), 36 | .fileAccess(.userSelectedFiles, mode: .readWrite) 37 | ], 38 | appCategory: .education 39 | ) 40 | ], 41 | targets: [ 42 | .executableTarget( 43 | name: "AppModule", 44 | path: ".", 45 | resources: [ 46 | .process("Resources") 47 | ] 48 | ) 49 | ] 50 | ) -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 A. Zheng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Popover+Calculations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Popover+Calculations.swift 3 | // 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 3/19/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Popover { 12 | /// Updates the popover's frame using its size. 13 | func updateFrame(with size: CGSize?) { 14 | let frame = calculateFrame(from: size) 15 | context.size = size 16 | context.staticFrame = frame 17 | context.frame = frame 18 | } 19 | 20 | /// Calculate the popover's frame based on its size and position. 21 | func calculateFrame(from size: CGSize?) -> CGRect { 22 | guard let window = context.presentedPopoverContainer?.window else { return .zero } 23 | 24 | switch attributes.position { 25 | case let .absolute(originAnchor, popoverAnchor): 26 | var popoverFrame = attributes.position.absoluteFrame( 27 | originAnchor: originAnchor, 28 | popoverAnchor: popoverAnchor, 29 | originFrame: attributes.sourceFrame().inset(by: attributes.sourceFrameInset), 30 | popoverSize: size ?? .zero 31 | ) 32 | 33 | let screenEdgePadding = attributes.screenEdgePadding 34 | 35 | let safeWindowFrame = window.safeAreaLayoutGuide.layoutFrame 36 | let maxX = safeWindowFrame.maxX - screenEdgePadding.right 37 | let maxY = safeWindowFrame.maxY - screenEdgePadding.bottom 38 | 39 | /// Popover overflows on left/top side. 40 | if popoverFrame.origin.x < screenEdgePadding.left { 41 | popoverFrame.origin.x = screenEdgePadding.left 42 | } 43 | if popoverFrame.origin.y < screenEdgePadding.top { 44 | popoverFrame.origin.y = screenEdgePadding.top 45 | } 46 | 47 | /// Popover overflows on the right/bottom side. 48 | if popoverFrame.maxX > maxX { 49 | let difference = popoverFrame.maxX - maxX 50 | popoverFrame.origin.x -= difference 51 | } 52 | if popoverFrame.maxY > maxY { 53 | let difference = popoverFrame.maxY - maxY 54 | popoverFrame.origin.y -= difference 55 | } 56 | 57 | return popoverFrame 58 | case let .relative(popoverAnchors): 59 | 60 | /// Set the selected anchor to the first one. 61 | if context.selectedAnchor == nil { 62 | context.selectedAnchor = popoverAnchors.first 63 | } 64 | 65 | let popoverFrame = attributes.position.relativeFrame( 66 | selectedAnchor: context.selectedAnchor ?? popoverAnchors.first ?? .bottom, 67 | containerFrame: attributes.sourceFrame().inset(by: attributes.sourceFrameInset), 68 | popoverSize: size ?? .zero 69 | ) 70 | 71 | return popoverFrame 72 | } 73 | } 74 | 75 | /// Calculate if the popover should be dismissed via drag **or** animated to another position (if using `.relative` positioning with multiple anchors). Called when the user stops dragging the popover. 76 | func positionChanged(to point: CGPoint) { 77 | let windowBounds = context.windowBounds 78 | 79 | if 80 | attributes.dismissal.mode.contains(.dragDown), 81 | point.y >= windowBounds.height - windowBounds.height * attributes.dismissal.dragDismissalProximity 82 | { 83 | if attributes.dismissal.dragMovesPopoverOffScreen { 84 | var newFrame = context.staticFrame 85 | newFrame.origin.y = windowBounds.height 86 | context.staticFrame = newFrame 87 | context.frame = newFrame 88 | } 89 | dismiss() 90 | return 91 | } 92 | if 93 | attributes.dismissal.mode.contains(.dragUp), 94 | point.y <= windowBounds.height * attributes.dismissal.dragDismissalProximity 95 | { 96 | if attributes.dismissal.dragMovesPopoverOffScreen { 97 | var newFrame = context.staticFrame 98 | newFrame.origin.y = -newFrame.height 99 | context.staticFrame = newFrame 100 | context.frame = newFrame 101 | } 102 | dismiss() 103 | return 104 | } 105 | 106 | if case let .relative(popoverAnchors) = attributes.position { 107 | let frame = attributes.sourceFrame().inset(by: attributes.sourceFrameInset) 108 | let size = context.size ?? .zero 109 | 110 | let closestAnchor = attributes.position.relativeClosestAnchor( 111 | popoverAnchors: popoverAnchors, 112 | containerFrame: frame, 113 | popoverSize: size, 114 | targetPoint: point 115 | ) 116 | let popoverFrame = attributes.position.relativeFrame( 117 | selectedAnchor: closestAnchor, 118 | containerFrame: frame, 119 | popoverSize: size 120 | ) 121 | 122 | context.selectedAnchor = closestAnchor 123 | context.staticFrame = popoverFrame 124 | context.frame = popoverFrame 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Popover+Context.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Popover+Context.swift 3 | // 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 3/19/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | 12 | public extension Popover { 13 | /** 14 | The popover's view model (stores attributes, frame, and other visible traits). 15 | */ 16 | class Context: Identifiable, ObservableObject { 17 | /// The popover's ID. Must be unique, unless replacing an existing popover. 18 | public var id = UUID() 19 | 20 | /// The popover's customizable properties. 21 | public var attributes = Attributes() 22 | 23 | /// The popover's dynamic size, calculated from SwiftUI. If this is `nil`, the popover is not yet ready to be displayed. 24 | @Published public var size: CGSize? 25 | 26 | /// The frame of the popover, without drag gesture offset applied. 27 | @Published public var staticFrame = CGRect.zero 28 | 29 | /// The current frame of the popover. 30 | @Published public var frame = CGRect.zero 31 | 32 | /// The currently selected anchor, if the popover has a `.relative` position. 33 | @Published public var selectedAnchor: Popover.Attributes.Position.Anchor? 34 | 35 | /// If this is true, the popover is the replacement of another popover. 36 | @Published public var isReplacement = false 37 | 38 | /// For animation syncing. If this is not nil, the popover is in the middle of a frame refresh. 39 | public var transaction: Transaction? 40 | 41 | /// Notify when the context changed. 42 | public var changeSink: AnyCancellable? 43 | 44 | /// Indicates whether the popover can be dragged. 45 | public var isDraggingEnabled: Bool { 46 | get { 47 | popoverModel?.popoversDraggable ?? false 48 | } 49 | set { 50 | popoverModel?.popoversDraggable = newValue 51 | } 52 | } 53 | 54 | public var window: UIWindow { 55 | if let window = presentedPopoverContainer?.window { 56 | return window 57 | } else { 58 | print("[Popovers] - This popover is not tied to a window. Please file a bug report (https://github.com/aheze/Popovers/issues).") 59 | return UIWindow() 60 | } 61 | } 62 | 63 | /** 64 | The bounds of the window in which the `Popover` is being presented, or the `zero` frame if the popover has not been presented yet. 65 | */ 66 | public var windowBounds: CGRect { 67 | presentedPopoverContainer?.window?.bounds ?? .zero 68 | } 69 | 70 | /** 71 | For the SwiftUI `.popover` view modifier. This is for internal use only - use `Popover.Attributes.onDismiss` if you want to know when the popover is dismissed. 72 | 73 | This is called just after the popover is removed from the model - inside the view modifier, set `$present` to false when this is called. 74 | */ 75 | internal var onAutoDismiss: (() -> Void)? 76 | 77 | /// Invoked by the SwiftUI container view when the view has fully disappeared. 78 | internal var onDisappear: (() -> Void)? 79 | 80 | /// The `UIView` presenting this `Popover`, or `nil` if no popovers are currently being presented. 81 | internal var presentedPopoverContainer: UIView? 82 | 83 | internal var windowSublayersKeyValueObservationToken: NSKeyValueObservation? 84 | 85 | /// The `PopoverModel` managing the `Popover`. Sourced from the `presentedPopoverViewController`. 86 | private var popoverModel: PopoverModel? { 87 | return presentedPopoverContainer?.popoverModel 88 | } 89 | 90 | /// Create a context for the popover. You shouldn't need to use this - it's done automatically when you create a new popover. 91 | public init() { 92 | changeSink = objectWillChange.sink { [weak self] in 93 | guard let self = self else { return } 94 | DispatchQueue.main.async { 95 | self.attributes.onContextChange?(self) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Popover+Lifecycle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Popover+Lifecycle.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 1/4/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | #if os(iOS) 9 | import SwiftUI 10 | 11 | /** 12 | Present a popover. 13 | */ 14 | public extension Popover { 15 | /** 16 | Present a popover in a window. It may be easier to use the `UIViewController.present(_:)` convenience method instead. 17 | */ 18 | internal func present(in window: UIWindow) { 19 | /// Create a transaction for the presentation animation. 20 | let transaction = Transaction(animation: attributes.presentation.animation) 21 | 22 | /// Inject the transaction into the popover, so following frame calculations are animated smoothly. 23 | context.transaction = transaction 24 | 25 | /// Get the popover model that's tied to the window. 26 | let model = window.popoverModel 27 | 28 | /** 29 | Add the popover to the container view. 30 | */ 31 | func displayPopover(in container: PopoverGestureContainer) { 32 | withTransaction(transaction) { 33 | model.add(self) 34 | 35 | /// Stop VoiceOver from reading out background views if `blocksBackgroundTouches` is true. 36 | if attributes.blocksBackgroundTouches { 37 | container.accessibilityViewIsModal = true 38 | } 39 | 40 | /// Shift VoiceOver focus to the popover. 41 | if attributes.accessibility.shiftFocus { 42 | UIAccessibility.post(notification: .screenChanged, argument: nil) 43 | } 44 | } 45 | } 46 | 47 | /// Find the existing container view for popovers in this window. If it does not exist, we need to insert one. 48 | let container: PopoverGestureContainer 49 | if let existingContainer = window.popoverContainerView { 50 | container = existingContainer 51 | 52 | /// The container is already laid out in the window, so we can go ahead and show the popover. 53 | displayPopover(in: container) 54 | } else { 55 | container = PopoverGestureContainer(frame: window.bounds) 56 | 57 | /** 58 | Wait until the container is present in the view hierarchy before showing the popover, 59 | otherwise all the layout math will be working with wonky frames. 60 | */ 61 | container.onMovedToWindow = { [weak container] in 62 | if let container = container { 63 | displayPopover(in: container) 64 | } 65 | } 66 | 67 | window.addSubview(container) 68 | } 69 | 70 | if attributes.source == .stayAboveWindows { 71 | context.windowSublayersKeyValueObservationToken = window.layer.observe(\.sublayers) { _, _ in 72 | window.bringSubviewToFront(container) 73 | } 74 | } 75 | 76 | /// Hang on to the container for future dismiss/replace actions. 77 | context.presentedPopoverContainer = container 78 | } 79 | 80 | /** 81 | Dismiss a popover. 82 | 83 | - parameter transaction: An optional transaction that can be applied for the dismissal animation. 84 | */ 85 | func dismiss(transaction: Transaction? = nil) { 86 | guard let container = context.presentedPopoverContainer else { return } 87 | 88 | let model = container.popoverModel 89 | let dismissalTransaction = transaction ?? Transaction(animation: attributes.dismissal.animation) 90 | 91 | /// Clean up the container view controller if no more popovers are visible. 92 | context.onDisappear = { [weak context] in 93 | if model.popovers.isEmpty { 94 | context?.presentedPopoverContainer?.removeFromSuperview() 95 | context?.presentedPopoverContainer = nil 96 | } 97 | 98 | /// If at least one popover has `blocksBackgroundTouches` set to true, stop VoiceOver from reading out background views 99 | context?.presentedPopoverContainer?.accessibilityViewIsModal = model.popovers.contains { $0.attributes.blocksBackgroundTouches } 100 | } 101 | 102 | /// Remove this popover from the view model, dismissing it. 103 | withTransaction(dismissalTransaction) { 104 | model.remove(self) 105 | } 106 | 107 | /// Let the internal SwiftUI modifiers know that the popover was automatically dismissed. 108 | context.onAutoDismiss?() 109 | 110 | /// Let the client know that the popover was automatically dismissed. 111 | attributes.onDismiss?() 112 | } 113 | 114 | /** 115 | Replace this popover with another popover smoothly. 116 | */ 117 | func replace(with newPopover: Popover) { 118 | guard let popoverContainerViewController = context.presentedPopoverContainer else { return } 119 | 120 | let model = popoverContainerViewController.popoverModel 121 | 122 | /// Get the index of the previous popover. 123 | if let oldPopoverIndex = model.index(of: self) { 124 | /// Get the old popover's context. 125 | let oldContext = model.popovers[oldPopoverIndex].context 126 | 127 | /// Create a new transaction for the replacing animation. 128 | let transaction = Transaction(animation: newPopover.attributes.presentation.animation) 129 | 130 | /// Inject the transaction into the new popover, so following frame calculations are animated smoothly. 131 | newPopover.context.transaction = transaction 132 | 133 | /// Use the same `UIViewController` presenting the previous popover, so we animate the popover in the same container. 134 | newPopover.context.presentedPopoverContainer = oldContext.presentedPopoverContainer 135 | 136 | /// Set the popover as a replacement. 137 | newPopover.context.isReplacement = true 138 | 139 | /// Use same ID so that SwiftUI animates the change. 140 | newPopover.context.id = oldContext.id 141 | 142 | withTransaction(transaction) { 143 | /// Temporarily use the same size for a smooth animation. 144 | newPopover.updateFrame(with: oldContext.size) 145 | 146 | /// Replace the old popover with the new popover. 147 | model.popovers[oldPopoverIndex] = newPopover 148 | } 149 | } 150 | } 151 | } 152 | 153 | public extension UIResponder { 154 | /// Replace a popover with another popover. Convenience method for `Popover.replace(with:)`. 155 | func replace(_ oldPopover: Popover, with newPopover: Popover) { 156 | oldPopover.replace(with: newPopover) 157 | } 158 | 159 | /// Dismiss a popover. Convenience method for `Popover.dismiss(transaction:)`. 160 | func dismiss(_ popover: Popover) { 161 | popover.dismiss() 162 | } 163 | 164 | /** 165 | Get a currently-presented popover with a tag. Returns `nil` if no popover with the tag was found. 166 | - parameter tag: The tag of the popover to look for. 167 | */ 168 | func popover(tagged tag: AnyHashable) -> Popover? { 169 | return popoverModel.popover(tagged: tag) 170 | } 171 | 172 | /** 173 | Remove all popovers, or optionally the ones tagged with a `tag` that you supply. 174 | - parameter tag: If this isn't nil, only remove popovers tagged with this. 175 | */ 176 | func dismissAllPopovers(with tag: AnyHashable? = nil) { 177 | popoverModel.removeAllPopovers(with: tag) 178 | } 179 | } 180 | 181 | public extension UIViewController { 182 | /// Present a `Popover` using this `UIViewController` as its presentation context. 183 | func present(_ popover: Popover) { 184 | guard let window = view.window else { return } 185 | popover.present(in: window) 186 | } 187 | } 188 | 189 | extension UIView { 190 | var popoverContainerView: PopoverGestureContainer? { 191 | if let container = self as? PopoverGestureContainer { 192 | return container 193 | } else { 194 | for subview in subviews { 195 | if let container = subview.popoverContainerView { 196 | return container 197 | } 198 | } 199 | 200 | return nil 201 | } 202 | } 203 | } 204 | #endif 205 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Popover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Popover.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 12/23/21. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | #if os(iOS) 9 | import Combine 10 | import SwiftUI 11 | 12 | /** 13 | A view that is placed over other views. 14 | */ 15 | public struct Popover: Identifiable { 16 | /** 17 | Stores information about the popover. 18 | This includes the attributes, frame, and acts like a view model. If using SwiftUI, access it using `PopoverReader`. 19 | */ 20 | public var context: Context 21 | 22 | /// The view that the popover presents. 23 | public var view: AnyView 24 | 25 | /// A view that goes behind the popover. 26 | public var background: AnyView 27 | 28 | /** 29 | Convenience accessor for the popover's ID. 30 | */ 31 | public var id: UUID { 32 | get { 33 | context.id 34 | } set { 35 | context.id = newValue 36 | } 37 | } 38 | 39 | /// Convenience accessor for the popover's attributes. 40 | public var attributes: Attributes { 41 | get { 42 | context.attributes 43 | } set { 44 | context.attributes = newValue 45 | } 46 | } 47 | 48 | /** 49 | A popover. 50 | - parameter attributes: Customize the popover. 51 | - parameter view: The view to present. 52 | */ 53 | public init( 54 | attributes: Attributes = .init(), 55 | @ViewBuilder view: @escaping () -> Content 56 | ) { 57 | let context = Context() 58 | context.attributes = attributes 59 | self.context = context 60 | self.view = AnyView(view().environmentObject(context)) 61 | background = AnyView(Color.clear) 62 | } 63 | 64 | /** 65 | A popover with a background. 66 | - parameter attributes: Customize the popover. 67 | - parameter view: The view to present. 68 | - parameter background: The view to present in the background. 69 | */ 70 | public init( 71 | attributes: Attributes = .init(), 72 | @ViewBuilder view: @escaping () -> MainContent, 73 | @ViewBuilder background: @escaping () -> BackgroundContent 74 | ) { 75 | let context = Context() 76 | context.attributes = attributes 77 | self.context = context 78 | self.view = AnyView(view().environmentObject(self.context)) 79 | self.background = AnyView(background().environmentObject(self.context)) 80 | } 81 | } 82 | 83 | extension Popover: Equatable { 84 | /// Conform to equatable. 85 | public static func == (lhs: Popover, rhs: Popover) -> Bool { 86 | return lhs.id == rhs.id 87 | } 88 | } 89 | #endif 90 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/PopoverGestureContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PopoverGestureContainer.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 12/23/21. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /// A hosting view for `PopoverContainerView` with tap filtering. 13 | class PopoverGestureContainer: UIView { 14 | /// A closure to be invoked when this view is inserted into a window's view hierarchy. 15 | var onMovedToWindow: (() -> Void)? 16 | 17 | /// Create a new `PopoverGestureContainer`. 18 | override init(frame: CGRect) { 19 | super.init(frame: frame) 20 | 21 | /// Allow resizing. 22 | autoresizingMask = [.flexibleWidth, .flexibleHeight] 23 | } 24 | 25 | /// If this is nil, the view hasn't been laid out yet. 26 | var previousBounds: CGRect? 27 | 28 | override func layoutSubviews() { 29 | super.layoutSubviews() 30 | 31 | /// Only update frames on a bounds change. 32 | if let previousBounds = previousBounds, previousBounds != bounds { 33 | /// Orientation or screen bounds changed, so update popover frames. 34 | popoverModel.updateFramesAfterBoundsChange() 35 | } 36 | 37 | /// Store the bounds for later. 38 | previousBounds = bounds 39 | } 40 | 41 | override func didMoveToWindow() { 42 | super.didMoveToWindow() 43 | 44 | /// There might not be a window yet, but that's fine. Just wait until there's actually a window. 45 | guard let window = window else { return } 46 | 47 | /// Create the SwiftUI view that contains all the popovers. 48 | let popoverContainerView = PopoverContainerView(popoverModel: popoverModel) 49 | .environment(\.window, window) /// Inject the window. 50 | 51 | let hostingController = UIHostingController(rootView: popoverContainerView) 52 | hostingController.view.frame = bounds 53 | hostingController.view.backgroundColor = .clear 54 | hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 55 | 56 | addSubview(hostingController.view) 57 | 58 | /// Ensure the view is laid out so that SwiftUI animations don't stutter. 59 | setNeedsLayout() 60 | layoutIfNeeded() 61 | 62 | /// Let the presenter know that its window is available. 63 | onMovedToWindow?() 64 | } 65 | 66 | /** 67 | Determine if touches should land on popovers or pass through to the underlying view. 68 | 69 | The popover container view takes up the entire screen, so normally it would block all touches from going through. This method fixes that. 70 | */ 71 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 72 | /// Make sure the hit event was actually a touch and not a cursor hover or something else. 73 | guard event.map({ $0.type == .touches }) ?? true else { return nil } 74 | 75 | /// Only loop through the popovers that are in this window. 76 | let popovers = popoverModel.popovers 77 | 78 | /// The current popovers' frames 79 | let popoverFrames = popovers.map { $0.context.frame } 80 | 81 | /// Loop through the popovers and see if the touch hit it. 82 | /// `reversed` to start from the most recently presented popovers, working backwards. 83 | for popover in popovers.reversed() { 84 | /// Check it the popover was hit. 85 | if popover.context.frame.contains(point) { 86 | /// Dismiss other popovers if they have `tapOutsideIncludesOtherPopovers` set to true. 87 | for popoverToDismiss in popovers { 88 | if 89 | popoverToDismiss != popover, 90 | !popoverToDismiss.context.frame.contains(point) /// The popover's frame doesn't contain the touch point. 91 | { 92 | dismissPopoverIfNecessary(popoverFrames: popoverFrames, point: point, popoverToDismiss: popoverToDismiss) 93 | } 94 | } 95 | 96 | /// Receive the touch and block it from going through. 97 | return super.hitTest(point, with: event) 98 | } 99 | 100 | /// The popover was not hit, so let it know that the user tapped outside. 101 | popover.attributes.onTapOutside?() 102 | 103 | /// If the popover has `blocksBackgroundTouches` set to true, stop underlying views from receiving the touch. 104 | if popover.attributes.blocksBackgroundTouches { 105 | let allowedFrames = popover.attributes.blocksBackgroundTouchesAllowedFrames() 106 | 107 | if allowedFrames.contains(where: { $0.contains(point) }) { 108 | dismissPopoverIfNecessary(popoverFrames: popoverFrames, point: point, popoverToDismiss: popover) 109 | 110 | return nil 111 | } else { 112 | /// Receive the touch and block it from going through. 113 | return super.hitTest(point, with: event) 114 | } 115 | } 116 | 117 | /// Check if the touch hit an excluded view. If so, don't dismiss it. 118 | if popover.attributes.dismissal.mode.contains(.tapOutside) { 119 | let excludedFrames = popover.attributes.dismissal.excludedFrames() 120 | if excludedFrames.contains(where: { $0.contains(point) }) { 121 | /** 122 | The touch hit an excluded view, so don't dismiss it. 123 | However, if the touch hit another popover, block it from passing through. 124 | */ 125 | if popoverFrames.contains(where: { $0.contains(point) }) { 126 | return super.hitTest(point, with: event) 127 | } else { 128 | return nil 129 | } 130 | } 131 | } 132 | 133 | /// All checks did not pass, which means the touch landed outside the popover. So, dismiss it if necessary. 134 | dismissPopoverIfNecessary(popoverFrames: popoverFrames, point: point, popoverToDismiss: popover) 135 | } 136 | 137 | /// The touch did not hit any popover, so pass it through to the hit testing target. 138 | return nil 139 | } 140 | 141 | /// Dismiss a popover, knowing that its frame does not contain the touch. 142 | func dismissPopoverIfNecessary(popoverFrames: [CGRect], point: CGPoint, popoverToDismiss: Popover) { 143 | if 144 | popoverToDismiss.attributes.dismissal.mode.contains(.tapOutside), /// The popover can be automatically dismissed when tapped outside. 145 | popoverToDismiss.attributes.dismissal.tapOutsideIncludesOtherPopovers || /// The popover can be dismissed even if the touch hit another popover, **or...** 146 | !popoverFrames.contains(where: { $0.contains(point) }) /// ... no other popover frame contains the point (the touch landed outside) 147 | { 148 | popoverToDismiss.dismiss() 149 | } 150 | } 151 | 152 | /// Dismiss all popovers if the accessibility escape gesture was performed. 153 | override func accessibilityPerformEscape() -> Bool { 154 | for popover in popoverModel.popovers { 155 | popover.dismiss() 156 | } 157 | 158 | return true 159 | } 160 | 161 | /// Boilerplate code. 162 | @available(*, unavailable) 163 | required init?(coder _: NSCoder) { 164 | fatalError("[Popovers] - Create this view programmatically.") 165 | } 166 | } 167 | #endif 168 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/PopoverModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PopoverModel.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 12/23/21. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import Combine 11 | import SwiftUI 12 | 13 | /** 14 | The view model for presented popovers within a window. 15 | 16 | Each view model is scoped to a window, which retains the view model. 17 | Presenting or otherwise managing a popover automatically scopes interactions to the window of the current view hierarchy. 18 | */ 19 | class PopoverModel: ObservableObject { 20 | /// The currently-presented popovers. The oldest are in front, the newest at the end. 21 | @Published var popovers = [Popover]() 22 | 23 | /// Determines if the popovers can be dragged. 24 | @Published var popoversDraggable = true 25 | 26 | /// Store the frames of views (for excluding popover dismissal or source frames). 27 | @Published var frameTags: [AnyHashable: CGRect] = [:] 28 | 29 | /** 30 | Store frames of popover source views when presented using `.popover(selection:tag:attributes:view:)`. These frames are then used as excluded frames for dismissal. 31 | 32 | To opt out of this behavior, set `attributes.dismissal.excludedFrames` manually. To clear this array (usually when you present another view where the frames don't apply), use a `FrameTagReader` to call `FrameTagProxy.clearSavedFrames()`. 33 | */ 34 | @Published var selectionFrameTags: [AnyHashable: CGRect] = [:] 35 | 36 | /// Force the container view to update. 37 | func reload() { 38 | objectWillChange.send() 39 | } 40 | 41 | /** 42 | Refresh the popovers with a new transaction. 43 | 44 | This is called when the screen bounds changes - by setting a transaction for each popover, 45 | the `PopoverContainerView` knows that it needs to animate a change (processed in `sizeReader`). 46 | */ 47 | func refresh(with transaction: Transaction?) { 48 | /// Set each popovers's transaction to the new transaction to keep the smooth animation. 49 | for popover in popovers { 50 | popover.context.transaction = transaction 51 | } 52 | 53 | /// Update all popovers. 54 | reload() 55 | } 56 | 57 | /// Adds a `Popover` to this model. 58 | func add(_ popover: Popover) { 59 | popovers.append(popover) 60 | } 61 | 62 | /// Removes a `Popover` from this model. 63 | func remove(_ popover: Popover) { 64 | popovers.removeAll { $0 == popover } 65 | } 66 | 67 | /** 68 | Remove all popovers, or optionally the ones tagged with a `tag` that you supply. 69 | - parameter tag: If this isn't nil, only remove popovers tagged with this. 70 | */ 71 | func removeAllPopovers(with tag: AnyHashable? = nil) { 72 | if let tag = tag { 73 | popovers.removeAll(where: { $0.attributes.tag == tag }) 74 | } else { 75 | popovers.removeAll() 76 | } 77 | } 78 | 79 | /// Get the index in the for a popover. Returns `nil` if the popover is not in the array. 80 | func index(of popover: Popover) -> Int? { 81 | return popovers.firstIndex(of: popover) 82 | } 83 | 84 | /** 85 | Get a currently-presented popover with a tag. Returns `nil` if no popover with the tag was found. 86 | - parameter tag: The tag of the popover to look for. 87 | */ 88 | func popover(tagged tag: AnyHashable) -> Popover? { 89 | let matchingPopovers = popovers.filter { $0.attributes.tag == tag } 90 | if matchingPopovers.count > 1 { 91 | print("[Popovers] - Warning - There are \(matchingPopovers.count) popovers tagged '\(tag)'. Tags should be unique. Try dismissing all existing popovers first.") 92 | } 93 | return matchingPopovers.first 94 | } 95 | 96 | /** 97 | Update all popover frames. 98 | 99 | This is called when the device rotates or has a bounds change. 100 | */ 101 | func updateFramesAfterBoundsChange() { 102 | /** 103 | First, update all popovers anyway. 104 | 105 | For some reason, relative positioning + `.center` doesn't need the rotation animation to complete before having a size change. 106 | */ 107 | for popover in popovers { 108 | popover.updateFrame(with: popover.context.size) 109 | } 110 | 111 | /// Reload the container view. 112 | reload() 113 | 114 | /// Some other popovers need to wait until the rotation has completed before updating. 115 | DispatchQueue.main.asyncAfter(deadline: .now() + Popovers.frameUpdateDelayAfterBoundsChange) { 116 | self.refresh(with: Transaction(animation: .default)) 117 | } 118 | } 119 | 120 | /// Access this with `UIResponder.frameTagged(_:)` if inside a `WindowReader`, or `Popover.Context.frameTagged(_:)` if inside a `PopoverReader.` 121 | func frame(tagged tag: AnyHashable) -> CGRect { 122 | let frame = frameTags[tag] 123 | return frame ?? .zero 124 | } 125 | } 126 | #endif 127 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/PopoverUtilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PopoverUtilities.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 12/23/21. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import Combine 11 | import SwiftUI 12 | 13 | public extension UIView { 14 | /// Convert a view's frame to global coordinates, which are needed for `sourceFrame` and `excludedFrames.` 15 | func windowFrame() -> CGRect { 16 | return convert(bounds, to: nil) 17 | } 18 | } 19 | 20 | public extension Optional where Wrapped: UIView { 21 | /// Convert a view's frame to global coordinates, which are needed for `sourceFrame` and `excludedFrames.` This is a convenience overload for optional `UIView`s. 22 | func windowFrame() -> CGRect { 23 | if let view = self { 24 | return view.windowFrame() 25 | } 26 | return .zero 27 | } 28 | } 29 | 30 | public extension View { 31 | /// Read a view's frame. From https://stackoverflow.com/a/66822461/14351818 32 | func frameReader(in coordinateSpace: CoordinateSpace = .global, rect: @escaping (CGRect) -> Void) -> some View { 33 | return background( 34 | GeometryReader { geometry in 35 | let frame = geometry.frame(in: coordinateSpace) 36 | 37 | Color.clear 38 | .onValueChange(of: frame) { _, newValue in 39 | rect(newValue) 40 | } 41 | .onAppear { 42 | rect(frame) 43 | } 44 | } 45 | .hidden() 46 | ) 47 | } 48 | 49 | /** 50 | Read a view's size. The closure is called whenever the size itself changes, or the transaction changes (in the event of a screen rotation.) 51 | 52 | From https://stackoverflow.com/a/66822461/14351818 53 | */ 54 | func sizeReader(transaction: Transaction? = nil, size: @escaping (CGSize) -> Void) -> some View { 55 | return background( 56 | GeometryReader { geometry in 57 | Color.clear 58 | .preference(key: ContentSizeReaderPreferenceKey.self, value: geometry.size) 59 | .onPreferenceChange(ContentSizeReaderPreferenceKey.self) { newValue in 60 | DispatchQueue.main.async { 61 | size(newValue) 62 | } 63 | } 64 | .onValueChange(of: transaction?.animation) { _, _ in 65 | DispatchQueue.main.async { 66 | size(geometry.size) 67 | } 68 | } 69 | } 70 | .hidden() 71 | ) 72 | } 73 | } 74 | 75 | struct ContentFrameReaderPreferenceKey: PreferenceKey { 76 | static var defaultValue: CGRect { return CGRect() } 77 | static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } 78 | } 79 | 80 | struct ContentSizeReaderPreferenceKey: PreferenceKey { 81 | static var defaultValue: CGSize { return CGSize() } 82 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() } 83 | } 84 | 85 | public extension UIColor { 86 | /** 87 | Create a UIColor from a hex code. 88 | 89 | Example: 90 | 91 | let color = UIColor(hex: 0x00aeef) 92 | */ 93 | convenience init(hex: UInt, alpha: CGFloat = 1) { 94 | self.init( 95 | red: CGFloat((hex & 0xFF0000) >> 16) / 255.0, 96 | green: CGFloat((hex & 0x00FF00) >> 8) / 255.0, 97 | blue: CGFloat(hex & 0x0000FF) / 255.0, 98 | alpha: alpha 99 | ) 100 | } 101 | } 102 | 103 | /// Position a view using a rectangular frame. Access using `.frame(rect:)`. 104 | struct FrameRectModifier: ViewModifier { 105 | let rect: CGRect 106 | func body(content: Content) -> some View { 107 | content 108 | .frame(width: rect.width, height: rect.height, alignment: .topLeading) 109 | .position(x: rect.origin.x + rect.width / 2, y: rect.origin.y + rect.height / 2) 110 | } 111 | } 112 | 113 | public extension View { 114 | /// Position a view using a rectangular frame. 115 | func frame(rect: CGRect) -> some View { 116 | return modifier(FrameRectModifier(rect: rect)) 117 | } 118 | } 119 | 120 | /// For easier CGPoint math 121 | public extension CGPoint { 122 | /// Add 2 CGPoints. 123 | static func + (left: CGPoint, right: CGPoint) -> CGPoint { 124 | return CGPoint(x: left.x + right.x, y: left.y + right.y) 125 | } 126 | 127 | /// Subtract 2 CGPoints. 128 | static func - (left: CGPoint, right: CGPoint) -> CGPoint { 129 | return CGPoint(x: left.x - right.x, y: left.y - right.y) 130 | } 131 | } 132 | 133 | /// Get the distance between 2 CGPoints. From https://www.hackingwithswift.com/example-code/core-graphics/how-to-calculate-the-distance-between-two-cgpoints 134 | public func CGPointDistanceSquared(from: CGPoint, to: CGPoint) -> CGFloat { 135 | return (from.x - to.x) * (from.x - to.x) + (from.y - to.y) * (from.y - to.y) 136 | } 137 | 138 | public extension Shape { 139 | /// Fill and stroke a shape at the same time. https://www.hackingwithswift.com/quick-start/swiftui/how-to-fill-and-stroke-shapes-at-the-same-time 140 | func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: CGFloat = 1) -> some View { 141 | stroke(strokeStyle, lineWidth: lineWidth) 142 | .background(fill(fillStyle)) 143 | } 144 | } 145 | 146 | public extension InsettableShape { 147 | /// Fill and stroke a shape at the same time. https://www.hackingwithswift.com/quick-start/swiftui/how-to-fill-and-stroke-shapes-at-the-same-time 148 | func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: CGFloat = 1) -> some View { 149 | strokeBorder(strokeStyle, lineWidth: lineWidth) 150 | .background(fill(fillStyle)) 151 | } 152 | } 153 | 154 | public extension UIEdgeInsets { 155 | /// The left + right insets. 156 | var horizontal: CGFloat { 157 | get { 158 | left + right 159 | } set { 160 | left = newValue 161 | right = newValue 162 | } 163 | } 164 | 165 | /// The top + bottom insets. 166 | var vertical: CGFloat { 167 | get { 168 | top + bottom 169 | } set { 170 | top = newValue 171 | bottom = newValue 172 | } 173 | } 174 | 175 | /// Create equal insets on all 4 sides. 176 | init(_ inset: CGFloat) { 177 | self = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset) 178 | } 179 | } 180 | 181 | /// Detect changes in bindings (fallback of `.onChange` for iOS 13+). From https://stackoverflow.com/a/64402663/14351818 182 | struct ChangeObserver: View { 183 | let content: Content 184 | let value: Value 185 | let action: (Value, Value) -> Void 186 | 187 | init(value: Value, action: @escaping (Value, Value) -> Void, content: @escaping () -> Content) { 188 | self.value = value 189 | self.action = action 190 | self.content = content() 191 | _oldValue = State(initialValue: value) 192 | } 193 | 194 | @State private var oldValue: Value 195 | 196 | var body: some View { 197 | DispatchQueue.main.async { 198 | if oldValue != value { 199 | action(oldValue, value) 200 | oldValue = value 201 | } 202 | } 203 | return content 204 | } 205 | } 206 | 207 | public extension View { 208 | /// Detect changes in bindings (fallback of `.onChange` for iOS 13+). 209 | func onValueChange( 210 | of value: Value, 211 | perform action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void 212 | ) -> some View { 213 | ChangeObserver(value: value, action: action) { 214 | self 215 | } 216 | } 217 | } 218 | 219 | #endif 220 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/PopoverWindows.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PopoverWindows.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 1/4/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /** 13 | Popovers supports multiple windows (iOS) by associating each `PopoverModel` with a window. 14 | */ 15 | 16 | /// A map of `PopoverModel`s scoped to each window. 17 | class WindowPopoverModels { 18 | /// The singleton `WindowPopoverMap` instance. 19 | static let shared = WindowPopoverModels() 20 | 21 | /** 22 | Aggregates the collection of models applicable to each `UIWindow` in the application. 23 | 24 | `UIWindow` references are weakly retained to avoid us leaking application scenes that have been disposed of by iOS, 25 | e.g. when dismissed from the multitasking UI or explicitly closed by the app. 26 | */ 27 | private var windowModels = [Weak: PopoverModel]() 28 | 29 | private init() { 30 | /// Enforcing singleton by marking `init` as private. 31 | } 32 | 33 | /** 34 | Retrieves the `PopoverModel` associated with the given `UIWindow`. 35 | 36 | When a `PopoverModel` already exists for the given `UIWindow`, the same reference will be returned by this function. 37 | Otherwise, a new model is created and associated with the window. 38 | 39 | - parameter window: The `UIWindow` whose `PopoverModel` is being requested, e.g. to present a popover. 40 | - Returns: The `PopoverModel` used to model the visible popovers for the given window. 41 | */ 42 | func popoverModel(for window: UIWindow) -> PopoverModel { 43 | /** 44 | Continually remove entries that refer to `UIWindow`s that are no longer about. 45 | The view hierarchies have already been dismantled - this is just for our own book keeping. 46 | */ 47 | pruneDeallocatedWindowModels() 48 | 49 | if let existingModel = existingPopoverModel(for: window) { 50 | return existingModel 51 | } else { 52 | return prepareAndRetainModel(for: window) 53 | } 54 | } 55 | 56 | private func pruneDeallocatedWindowModels() { 57 | let keysToRemove = windowModels.keys.filter(\.isPointeeDeallocated) 58 | for key in keysToRemove { 59 | windowModels[key] = nil 60 | } 61 | } 62 | 63 | /// Get an existing popover model for this window if it exists. 64 | private func existingPopoverModel(for window: UIWindow) -> PopoverModel? { 65 | return windowModels.first(where: { holder, _ in holder.pointee === window })?.value 66 | } 67 | 68 | private func prepareAndRetainModel(for window: UIWindow) -> PopoverModel { 69 | let newModel = PopoverModel() 70 | let weakWindowReference = Weak(pointee: window) 71 | windowModels[weakWindowReference] = newModel 72 | 73 | return newModel 74 | } 75 | 76 | /// Container type to enable storage of an object type without incrementing its retain count. 77 | private class Weak: NSObject where T: AnyObject { 78 | private(set) weak var pointee: T? 79 | 80 | var isPointeeDeallocated: Bool { 81 | pointee == nil 82 | } 83 | 84 | init(pointee: T) { 85 | self.pointee = pointee 86 | } 87 | } 88 | } 89 | 90 | extension UIResponder { 91 | /** 92 | The `PopoverModel` in the current responder chain. 93 | 94 | Each responder chain hosts a single `PopoverModel` at the window level. 95 | Each scene containing a separate window will contain its own `PopoverModel`, scoping the layout code to each window. 96 | 97 | - Important: Attempting to request the `PopoverModel` for a responder not present in the chain is programmer error. 98 | */ 99 | var popoverModel: PopoverModel { 100 | /// If we're a view, continue to walk up the responder chain until we hit the root view. 101 | if let view = self as? UIView, let superview = view.superview { 102 | return superview.popoverModel 103 | } 104 | 105 | /// If we're a window, we define the scoping for the model - access it. 106 | if let window = self as? UIWindow { 107 | return WindowPopoverModels.shared.popoverModel(for: window) 108 | } 109 | 110 | /// If we're a view controller, begin walking the responder chain up to the root view. 111 | if let viewController = self as? UIViewController { 112 | return viewController.view.popoverModel 113 | } 114 | 115 | print("[Popovers] - No `PopoverModel` present in responder chain (\(self)) - has the source view been installed into a window? Please file a bug report (https://github.com/aheze/Popovers/issues).") 116 | 117 | return PopoverModel() 118 | } 119 | } 120 | 121 | /// For passing the hosting window into the environment. 122 | extension EnvironmentValues { 123 | /// Designates the `UIWindow` hosting the views within the current environment. 124 | var window: UIWindow? { 125 | get { 126 | self[WindowEnvironmentKey.self] 127 | } 128 | set { 129 | self[WindowEnvironmentKey.self] = newValue 130 | } 131 | } 132 | 133 | private struct WindowEnvironmentKey: EnvironmentKey { 134 | typealias Value = UIWindow? 135 | 136 | static var defaultValue: UIWindow? = nil 137 | } 138 | } 139 | #endif 140 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Popovers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Popovers.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 1/17/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /** 13 | A collection of constants. 14 | */ 15 | public enum Popovers { 16 | /// The minimum distance a popover needs to be dragged before it starts getting offset. 17 | public static var minimumDragDistance = CGFloat(2) 18 | 19 | /// The delay after a bounds change before recalculating popover frames. 20 | public static var frameUpdateDelayAfterBoundsChange = CGFloat(0.6) 21 | } 22 | #endif 23 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/SwiftUI/FrameTag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FrameTag.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 12/23/21. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /** 13 | Frame tags are used to store the frames. 14 | **Warning:** this won't update the parent view, so is only valid for use inside a popover. 15 | If you need to use frame later, use `frameReader` instead. 16 | */ 17 | 18 | /// Store a view's frame for later use. 19 | struct FrameTagModifier: ViewModifier { 20 | /// The name of the frame. 21 | let tag: AnyHashable 22 | @State var frame = CGRect.zero 23 | 24 | func body(content: Content) -> some View { 25 | WindowReader { window in 26 | content 27 | .frameReader { frame in 28 | self.frame = frame 29 | 30 | if let window = window { 31 | window.save(frame, for: tag) 32 | } 33 | } 34 | .onValueChange(of: window) { _, newValue in 35 | if let window = window { 36 | window.save(frame, for: tag) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | public extension View { 44 | /** 45 | Tag a view and store its frame. Access using `Popovers.frameTagged(_:)`. 46 | 47 | You can use this for supplying source frames or excluded frames. **Do not** use it anywhere else, due to State re-rendering issues. 48 | 49 | - parameter tag: The tag for the frame 50 | */ 51 | func frameTag(_ tag: AnyHashable) -> some View { 52 | return modifier(FrameTagModifier(tag: tag)) 53 | } 54 | } 55 | 56 | public extension UIResponder { 57 | /** 58 | Get the saved frame of a frame-tagged view inside this window. You must first set the frame using `.frameTag(_:)`. 59 | - parameter tag: The tag that you used for the frame. 60 | - Returns: The frame of a frame-tagged view, or `nil` if no view with the tag exists. 61 | */ 62 | func frameTagged(_ tag: AnyHashable) -> CGRect { 63 | return popoverModel.frame(tagged: tag) 64 | } 65 | 66 | /** 67 | Remove all saved frames inside this window for `.popover(selection:tag:attributes:view:)`. 68 | Call this method when you present another view where the frames don't apply. 69 | */ 70 | func clearSavedFrames() { 71 | popoverModel.selectionFrameTags.removeAll() 72 | } 73 | 74 | /// Save a frame in this window's `frameTags`. 75 | internal func save(_ frame: CGRect, for tag: AnyHashable) { 76 | popoverModel.frameTags[tag] = frame 77 | } 78 | } 79 | 80 | public extension Optional where Wrapped: UIResponder { 81 | /** 82 | Get the saved frame of a frame-tagged view inside this window. You must first set the frame using `.frameTag(_:)`. This is a convenience overload for optional `UIResponder`s. 83 | - parameter tag: The tag that you used for the frame. 84 | - Returns: The frame of a frame-tagged view, or `nil` if no view with the tag exists. 85 | */ 86 | func frameTagged(_ tag: AnyHashable) -> CGRect { 87 | if let responder = self { 88 | return responder.frameTagged(tag) 89 | } 90 | return .zero 91 | } 92 | } 93 | #endif 94 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/SwiftUI/Readers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Readers.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 12/23/21. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /** 13 | Read the popover's context from within its `view` or `background`. 14 | Use this just like `GeometryReader`. 15 | 16 | **Warning:** This must be placed inside a popover's `view` or `background`. 17 | */ 18 | public struct PopoverReader: View { 19 | /// Read the popover's context from within its `view` or `background`. 20 | public init(@ViewBuilder view: @escaping (Popover.Context) -> Content) { 21 | self.view = view 22 | } 23 | 24 | /// The parent view. 25 | @ViewBuilder var view: (Popover.Context) -> Content 26 | 27 | /// The popover's context (passed down from `Popover.swift`). 28 | @EnvironmentObject var context: Popover.Context 29 | 30 | public var body: some View { 31 | /// Pass the context down. 32 | view(context) 33 | } 34 | } 35 | 36 | /** 37 | Read the current `UIWindow` that hosts the view. 38 | Use this just like `GeometryReader`. 39 | 40 | **Warning:** Do *not* place this inside a popover's `view` or its `background`. 41 | Instead, use the `window` property of the popover's context. 42 | */ 43 | public struct WindowReader: View { 44 | /// Your SwiftUI view. 45 | public let view: (UIWindow?) -> Content 46 | 47 | /// The read window. 48 | @StateObject var windowViewModel = WindowViewModel() 49 | 50 | /// Reads the `UIWindow` that hosts some SwiftUI content. 51 | public init(@ViewBuilder view: @escaping (UIWindow?) -> Content) { 52 | self.view = view 53 | } 54 | 55 | public var body: some View { 56 | view(windowViewModel.window) 57 | // .id(windowViewModel.window) 58 | .background( 59 | WindowHandlerRepresentable(windowViewModel: windowViewModel) 60 | ) 61 | 62 | } 63 | 64 | /// A wrapper view to read the parent window. 65 | private struct WindowHandlerRepresentable: UIViewRepresentable { 66 | @ObservedObject var windowViewModel: WindowViewModel 67 | 68 | func makeUIView(context _: Context) -> WindowHandler { 69 | return WindowHandler(windowViewModel: self.windowViewModel) 70 | } 71 | 72 | func updateUIView(_: WindowHandler, context _: Context) {} 73 | } 74 | 75 | private class WindowHandler: UIView { 76 | var windowViewModel: WindowViewModel 77 | 78 | init(windowViewModel: WindowViewModel) { 79 | self.windowViewModel = windowViewModel 80 | super.init(frame: .zero) 81 | backgroundColor = .clear 82 | } 83 | 84 | @available(*, unavailable) 85 | required init?(coder _: NSCoder) { 86 | fatalError("[Popovers] - Create this view programmatically.") 87 | } 88 | 89 | override func didMoveToWindow() { 90 | super.didMoveToWindow() 91 | 92 | DispatchQueue.main.async { 93 | /// Set the window. 94 | self.windowViewModel.window = self.window 95 | } 96 | } 97 | } 98 | } 99 | 100 | class WindowViewModel: ObservableObject { 101 | @Published var window: UIWindow? 102 | } 103 | 104 | #endif 105 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Alert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Alert.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 2/4/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | public extension Templates { 13 | /// A button style to resemble that of a system alert. 14 | struct AlertButtonStyle: ButtonStyle { 15 | /// A button style to resemble that of a system alert. 16 | public init() {} 17 | public func makeBody(configuration: Configuration) -> some View { 18 | configuration.label 19 | .frame(maxWidth: .infinity) 20 | .contentShape(Rectangle()) 21 | .padding() 22 | .background( 23 | configuration.isPressed ? Templates.buttonHighlightColor : Color.clear 24 | ) 25 | } 26 | } 27 | } 28 | #endif 29 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Blur.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Blur.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 2/4/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | public extension Templates { 13 | /// Use UIKit blurs in SwiftUI. 14 | struct VisualEffectView: UIViewRepresentable { 15 | /// The blur's style. 16 | public var style: UIBlurEffect.Style 17 | 18 | /// Use UIKit blurs in SwiftUI. 19 | public init(_ style: UIBlurEffect.Style) { 20 | self.style = style 21 | } 22 | 23 | public func makeUIView(context: Context) -> UIVisualEffectView { 24 | UIVisualEffectView() 25 | } 26 | 27 | public func updateUIView(_ uiView: UIVisualEffectView, context: Context) { 28 | uiView.effect = UIBlurEffect(style: style) 29 | } 30 | } 31 | } 32 | #endif 33 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Container.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Container.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 2/4/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | public extension Templates { 13 | /** 14 | A standard container for popovers, complete with arrow. 15 | */ 16 | struct Container: View { 17 | /// Which side to place the arrow on. 18 | public var arrowSide: ArrowSide? 19 | 20 | /// The container's corner radius. 21 | public var cornerRadius = CGFloat(12) 22 | 23 | /// The container's background/fill color. 24 | public var backgroundColor = Color(.systemBackground) 25 | 26 | /// The shadow around the content view. 27 | public var shadow: Shadow? = .system 28 | 29 | /// The padding around the content view. 30 | public var padding = CGFloat(16) 31 | 32 | /// The content view. 33 | @ViewBuilder public var view: Content 34 | 35 | /** 36 | A standard container for popovers, complete with arrow. 37 | - parameter arrowSide: Which side to place the arrow on. 38 | - parameter cornerRadius: The container's corner radius. 39 | - parameter backgroundColor: The container's background/fill color. 40 | - parameter padding: The padding around the content view. 41 | - parameter view: The content view. 42 | */ 43 | public init( 44 | arrowSide: Templates.ArrowSide? = nil, 45 | cornerRadius: CGFloat = CGFloat(12), 46 | backgroundColor: Color = Color(.systemBackground), 47 | shadow: Shadow? = .system, 48 | padding: CGFloat = CGFloat(16), 49 | @ViewBuilder view: () -> Content 50 | ) { 51 | self.arrowSide = arrowSide 52 | self.cornerRadius = cornerRadius 53 | self.backgroundColor = backgroundColor 54 | self.shadow = shadow 55 | self.padding = padding 56 | self.view = view() 57 | } 58 | 59 | public var body: some View { 60 | PopoverReader { context in 61 | view 62 | .padding(padding) 63 | .background( 64 | BackgroundWithArrow( 65 | arrowSide: arrowSide ?? context.attributes.position.getArrowPosition(), 66 | cornerRadius: cornerRadius 67 | ) 68 | .fill(backgroundColor) 69 | .popoverShadowIfNeeded(shadow: shadow) 70 | ) 71 | } 72 | } 73 | } 74 | 75 | /// The side of the popover that the arrow should be placed on. 76 | /** 77 | 78 | top 79 | X──────────────X──────────────X 80 | | | 81 | | | 82 | left X X right 83 | | | 84 | | | 85 | X──────────────X──────────────X 86 | bottom 87 | */ 88 | enum ArrowSide { 89 | case top(ArrowAlignment) 90 | case right(ArrowAlignment) 91 | case bottom(ArrowAlignment) 92 | case left(ArrowAlignment) 93 | 94 | /// Place the arrow on the left, middle, or right on a side. 95 | /** 96 | 97 | mostCounterClockwise centered mostClockwise 98 | ────X──────────────────────X──────────────────────X──── 99 | | | 100 | * diagram is for `ArrowSide.top` 101 | */ 102 | public enum ArrowAlignment { 103 | case mostCounterClockwise 104 | case centered 105 | case mostClockwise 106 | } 107 | } 108 | } 109 | #endif 110 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Hero.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Hero.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 7/17/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | #if os(iOS) 12 | 13 | // MARK: Work in progress, not usable yet 14 | 15 | public extension Templates { 16 | class Hero: ObservableObject { 17 | public enum Selection { 18 | case a 19 | case b 20 | } 21 | 22 | @Published public var selection: Selection? 23 | 24 | public init() {} 25 | 26 | public func transitionForwards() { 27 | guard selection == nil else { return } 28 | selection = .a 29 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.09) { 30 | withAnimation { 31 | self.selection = .b 32 | } 33 | } 34 | 35 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { 36 | self.selection = nil 37 | } 38 | } 39 | 40 | public func moveForwards() { 41 | guard selection == nil else { return } 42 | selection = .a 43 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { 44 | withAnimation { 45 | self.selection = .b 46 | } 47 | } 48 | } 49 | 50 | public func moveBackwards() { 51 | guard selection == .b else { return } 52 | selection = .a 53 | 54 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { 55 | self.selection = nil 56 | } 57 | } 58 | 59 | public func toggleMove() { 60 | if selection == .none { 61 | moveForwards() 62 | } else { 63 | moveBackwards() 64 | } 65 | } 66 | } 67 | } 68 | #endif 69 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Menu/Menu+SwiftUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Menu+SwiftUI.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 6/14/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | public extension Templates { 13 | /** 14 | A built-from-scratch version of the system menu. 15 | */ 16 | @available(iOS 14.0, *) 17 | struct Menu: View { 18 | /// View model for the menu buttons. Should be `StateObject` to avoid getting recreated by SwiftUI, but this works on iOS 13. 19 | @StateObject var model: MenuModel 20 | 21 | /// View model for controlling menu gestures. 22 | @StateObject var gestureModel: MenuGestureModel 23 | 24 | /// Allow presenting from an external view via `$present`. 25 | @Binding var overridePresent: Bool 26 | 27 | /// The menu buttons. 28 | public let content: () -> Content 29 | 30 | /// The origin label. 31 | public let label: (Bool) -> GeneratorLabel 32 | 33 | /// Fade the origin label. 34 | @State var fadeLabel = false 35 | 36 | /** 37 | A built-from-scratch version of the system menu, for SwiftUI. 38 | */ 39 | public init( 40 | present: Binding = .constant(false), 41 | configuration buildConfiguration: @escaping ((inout MenuConfiguration) -> Void) = { _ in }, 42 | @ViewBuilder content: @escaping () -> Content, 43 | @ViewBuilder label: @escaping (Bool) -> GeneratorLabel 44 | ) { 45 | _overridePresent = present 46 | _model = StateObject(wrappedValue: MenuModel(buildConfiguration: buildConfiguration)) 47 | _gestureModel = StateObject(wrappedValue: MenuGestureModel()) 48 | self.content = content 49 | self.label = label 50 | } 51 | 52 | public var body: some View { 53 | WindowReader { window in 54 | label(fadeLabel) 55 | .frameTag(model.id) 56 | .contentShape(Rectangle()) 57 | .simultaneousGesture( 58 | DragGesture(minimumDistance: 0, coordinateSpace: .global) 59 | .onChanged { value in 60 | 61 | gestureModel.onDragChanged( 62 | newDragLocation: value.location, 63 | model: model, 64 | labelFrame: window.frameTagged(model.id), 65 | window: window 66 | ) { present in 67 | model.present = present 68 | } fadeLabel: { fade in 69 | fadeLabel = fade 70 | } 71 | } 72 | .onEnded { value in 73 | gestureModel.onDragEnded( 74 | newDragLocation: value.location, 75 | model: model, 76 | labelFrame: window.frameTagged(model.id), 77 | window: window 78 | ) { present in 79 | model.present = present 80 | } fadeLabel: { fade in 81 | fadeLabel = fade 82 | } 83 | } 84 | ) 85 | .onValueChange(of: model.present) { _, present in 86 | if !present { 87 | withAnimation(model.configuration.labelFadeAnimation) { 88 | fadeLabel = false 89 | model.selectedItemID = nil 90 | model.hoveringItemID = nil 91 | } 92 | overridePresent = present 93 | } 94 | } 95 | .onValueChange(of: overridePresent) { _, present in 96 | if present != model.present { 97 | model.present = present 98 | withAnimation(model.configuration.labelFadeAnimation) { 99 | fadeLabel = present 100 | } 101 | } 102 | } 103 | .popover( 104 | present: $model.present, 105 | attributes: { 106 | $0.position = .absolute( 107 | originAnchor: model.configuration.originAnchor, 108 | popoverAnchor: model.configuration.popoverAnchor 109 | ) 110 | $0.rubberBandingMode = .none 111 | $0.dismissal.excludedFrames = { 112 | [ 113 | window.frameTagged(model.id), 114 | ] 115 | + model.configuration.excludedFrames() 116 | } 117 | $0.sourceFrameInset = model.configuration.sourceFrameInset 118 | $0.screenEdgePadding = model.configuration.screenEdgePadding 119 | } 120 | ) { 121 | MenuView( 122 | model: model, 123 | content: content 124 | ) 125 | } background: { 126 | model.configuration.backgroundColor 127 | } 128 | } 129 | } 130 | } 131 | } 132 | #endif 133 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Menu/Menu+UIKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Menu+UIKit.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 2/5/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import Combine 11 | import SwiftUI 12 | 13 | public extension Templates { 14 | /// A built-from-scratch version of the system menu, for UIKit. 15 | class UIKitMenu: NSObject { 16 | // MARK: - Menu properties 17 | 18 | /// View model for the menu buttons. 19 | var model: MenuModel 20 | 21 | /// View model for controlling menu gestures. 22 | var gestureModel: MenuGestureModel 23 | 24 | /// The menu buttons. 25 | public let content: Content 26 | 27 | /// The origin label. 28 | public let sourceView: UIView 29 | 30 | /// Fade the origin label. 31 | var fadeLabel: ((Bool) -> Void)? 32 | 33 | // MARK: - UIKit properties 34 | 35 | var popover: Popover? 36 | var longPressGestureRecognizer: UILongPressGestureRecognizer! 37 | var cancellables = Set() 38 | 39 | /** 40 | A built-from-scratch version of the system menu, for UIKit. 41 | This initializer lets you pass in a multiple menu items. 42 | */ 43 | public init( 44 | sourceView: UIView, 45 | configuration buildConfiguration: @escaping ((inout MenuConfiguration) -> Void) = { _ in }, 46 | @ViewBuilder content: @escaping () -> Content, 47 | fadeLabel: ((Bool) -> Void)? = nil 48 | ) { 49 | self.sourceView = sourceView 50 | model = MenuModel(buildConfiguration: buildConfiguration) 51 | gestureModel = MenuGestureModel() 52 | self.content = content() 53 | self.fadeLabel = fadeLabel 54 | super.init() 55 | 56 | addGestureRecognizer() 57 | } 58 | 59 | /// Set up the drag gesture recognizer (enable "pull-down" behavior). 60 | func addGestureRecognizer() { 61 | let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(dragged)) 62 | longPressGestureRecognizer.minimumPressDuration = 0 63 | sourceView.addGestureRecognizer(longPressGestureRecognizer) 64 | sourceView.isUserInteractionEnabled = true 65 | } 66 | 67 | @objc func dragged(_ gestureRecognizer: UILongPressGestureRecognizer) { 68 | let location = gestureRecognizer.location(in: sourceView.window) 69 | 70 | if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { 71 | gestureModel.onDragChanged( 72 | newDragLocation: location, 73 | model: model, 74 | labelFrame: sourceView.windowFrame(), 75 | window: sourceView.window 76 | ) { [weak self] present in 77 | self?.updatePresent(present) 78 | } fadeLabel: { [weak self] fade in 79 | self?.fadeLabel?(fade) 80 | } 81 | } else { 82 | gestureModel.onDragEnded( 83 | newDragLocation: location, 84 | model: model, 85 | labelFrame: sourceView.windowFrame(), 86 | window: sourceView.window 87 | ) { [weak self] present in 88 | self?.updatePresent(present) 89 | } fadeLabel: { [weak self] fade in 90 | self?.fadeLabel?(fade) 91 | } 92 | } 93 | } 94 | 95 | /** 96 | Set `model.present` and show/hide the popover. 97 | 98 | This is called when a menu button is pressed, 99 | or some other action happened that should hide the menu. 100 | This is **not** called when the user taps outside the menu, 101 | since the menu would already be automatically dismissed. 102 | */ 103 | func updatePresent(_ present: Bool) { 104 | model.present = present 105 | 106 | if 107 | present, 108 | let window = sourceView.window 109 | { 110 | presentPopover() 111 | popover?.present(in: window) 112 | fadeLabel?(true) 113 | } else { 114 | popover?.dismiss() 115 | popover = nil 116 | fadeLabel?(false) 117 | } 118 | } 119 | 120 | /// Present the menu popover. 121 | func presentPopover() { 122 | let configuration = model.configuration 123 | 124 | var popover = Popover { [weak self] in 125 | if let self = self { 126 | MenuView(model: self.model) { 127 | self.content 128 | } 129 | } 130 | } background: { 131 | configuration.backgroundColor 132 | } 133 | 134 | popover.attributes.sourceFrame = { [weak sourceView] in sourceView.windowFrame() } 135 | popover.attributes.position = .absolute( 136 | originAnchor: configuration.originAnchor, 137 | popoverAnchor: configuration.popoverAnchor 138 | ) 139 | popover.attributes.rubberBandingMode = .none 140 | popover.attributes.dismissal.excludedFrames = { [weak self] in 141 | guard let self = self else { return [] } 142 | return [ 143 | self.sourceView.windowFrame(), 144 | ] 145 | + configuration.excludedFrames() 146 | } 147 | popover.attributes.sourceFrameInset = configuration.sourceFrameInset 148 | popover.attributes.screenEdgePadding = configuration.screenEdgePadding 149 | 150 | /** 151 | Make sure to set `model.present` back to false when the menu is dismissed. 152 | Don't call `updatePresent`, since the popover has already been automatically dismissed. 153 | */ 154 | popover.context.onAutoDismiss = { [weak self] in 155 | self?.model.present = false 156 | self?.fadeLabel?(false) 157 | } 158 | 159 | self.popover = popover 160 | } 161 | } 162 | } 163 | 164 | /// Control menu state externally. 165 | public extension Templates.UIKitMenu { 166 | /// Whether the menu is currently presented or not. 167 | var isPresented: Bool { 168 | model.present 169 | } 170 | 171 | /// Present the menu. 172 | func present() { 173 | updatePresent(true) 174 | } 175 | 176 | /// Dismiss the menu. 177 | func dismiss() { 178 | updatePresent(false) 179 | } 180 | } 181 | #endif 182 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Menu/Model/MenuGestureModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuGestureModel.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 6/14/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | extension Templates { 13 | /// Model for managing gestures that started on the source label. 14 | /// Gestures that started on the popover itself are handled by `MenuView`. 15 | class MenuGestureModel: ObservableObject { 16 | /// If the user is pressing down on the label, this will be a unique `UUID`. 17 | @Published var labelPressUUID: UUID? 18 | 19 | /** 20 | If the label was pressed/dragged when the menu was already presented. 21 | In this case, dismiss the menu if the user lifts their finger on the label. 22 | */ 23 | @Published var labelPressedWhenAlreadyPresented = false 24 | 25 | /// The current position of the user's finger. 26 | @Published var dragLocation: CGPoint? 27 | 28 | /// Process the drag gesture, updating the menu to match. 29 | func onDragChanged( 30 | newDragLocation: CGPoint, 31 | model: MenuModel, 32 | labelFrame: CGRect, 33 | window: UIWindow?, 34 | present: @escaping ((Bool) -> Void), 35 | fadeLabel: @escaping ((Bool) -> Void) 36 | ) { 37 | dragLocation = newDragLocation 38 | 39 | /// Reference this here instead of repeating `model.configuration` over and over again. 40 | let configuration = model.configuration 41 | 42 | if model.present == false { 43 | /// The menu is not yet presented. 44 | if labelPressUUID == nil { 45 | labelPressUUID = UUID() 46 | let currentUUID = labelPressUUID 47 | DispatchQueue.main.asyncAfter(deadline: .now() + configuration.holdDelay) { 48 | if 49 | currentUUID == self.labelPressUUID, 50 | let dragLocation = self.dragLocation /// check the location once again 51 | { 52 | if labelFrame.contains(dragLocation) { 53 | present(true) 54 | } 55 | } 56 | } 57 | } 58 | 59 | withAnimation(configuration.labelFadeAnimation) { 60 | let shouldFade = labelFrame.contains(newDragLocation) 61 | fadeLabel(shouldFade) 62 | } 63 | } else if labelPressUUID == nil { 64 | /// The menu was already presented. 65 | labelPressUUID = UUID() 66 | labelPressedWhenAlreadyPresented = true 67 | } else { 68 | /// Highlight the button that the user's finger is over. 69 | model.hoveringItemID = model.getItemID(from: newDragLocation) 70 | 71 | /// Rubber-band the menu. 72 | withAnimation { 73 | if let distance = model.getDistanceFromMenu(from: newDragLocation) { 74 | if configuration.scaleRange.contains(distance) { 75 | let percentage = (distance - configuration.scaleRange.lowerBound) / (configuration.scaleRange.upperBound - configuration.scaleRange.lowerBound) 76 | let scale = 1 - (1 - configuration.minimumScale) * percentage 77 | model.scale = scale 78 | } else if distance < configuration.scaleRange.lowerBound { 79 | model.scale = 1 80 | } else { 81 | model.scale = configuration.minimumScale 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | /// Process the drag gesture ending, updating the menu to match. 89 | func onDragEnded( 90 | newDragLocation: CGPoint, 91 | model: MenuModel, 92 | labelFrame: CGRect, 93 | window: UIWindow?, 94 | present: @escaping ((Bool) -> Void), 95 | fadeLabel: @escaping ((Bool) -> Void) 96 | ) { 97 | dragLocation = newDragLocation 98 | 99 | withAnimation { 100 | model.scale = 1 101 | } 102 | 103 | labelPressUUID = nil 104 | 105 | /// The user started long pressing when the menu was **already** presented. 106 | if labelPressedWhenAlreadyPresented { 107 | labelPressedWhenAlreadyPresented = false 108 | 109 | let selectedItemID = model.getItemID(from: newDragLocation) 110 | model.selectedItemID = selectedItemID 111 | model.hoveringItemID = nil 112 | 113 | /// The user lifted their finger on the label **and** it did not hit a menu item. 114 | if 115 | selectedItemID == nil, 116 | labelFrame.contains(newDragLocation) 117 | { 118 | present(false) 119 | } 120 | } else { 121 | if !model.present { 122 | if labelFrame.contains(newDragLocation) { 123 | present(true) 124 | } else { 125 | withAnimation(model.configuration.labelFadeAnimation) { 126 | fadeLabel(false) 127 | } 128 | } 129 | } else { 130 | let selectedItemID = model.getItemID(from: newDragLocation) 131 | model.selectedItemID = selectedItemID 132 | model.hoveringItemID = nil 133 | 134 | /// The user lifted their finger outside an item target. 135 | if selectedItemID == nil { 136 | model.configuration.onLiftWithoutSelecting?() 137 | } else if model.configuration.dismissAfterSelecting { 138 | /// Dismiss if the user lifted up their finger on an item. 139 | present(false) 140 | } 141 | } 142 | } 143 | } 144 | } 145 | } 146 | #endif 147 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Menu/Model/MenuModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuModel.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 2/6/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | extension Templates { 13 | typealias MenuItemID = UUID 14 | class MenuModel: ObservableObject { 15 | var buildConfiguration: ((inout MenuConfiguration) -> Void) = { _ in } 16 | 17 | var configuration: MenuConfiguration { 18 | var configuration = MenuConfiguration() 19 | buildConfiguration(&configuration) 20 | return configuration 21 | } 22 | 23 | /// A unique ID for the menu (to support multiple menus in the same screen). 24 | @Published var id = UUID() 25 | 26 | /// Whether to show the popover or not. 27 | @Published var present = false 28 | 29 | /// The popover's scale (for rubber banding). 30 | @Published var scale = CGFloat(1) 31 | 32 | /// The index of the menu button that the user's finger hovers on. 33 | @Published var hoveringItemID: MenuItemID? 34 | 35 | /// The selected menu button if it exists. 36 | @Published var selectedItemID: MenuItemID? 37 | 38 | /// The frames of the menu buttons, relative to the window. 39 | @Published var frames = [MenuItemID: CGRect]() 40 | 41 | /// The frame of the menu in global coordinates. 42 | @Published var menuFrame = CGRect.zero 43 | 44 | init(buildConfiguration: @escaping ((inout MenuConfiguration) -> Void) = { _ in }) { 45 | self.buildConfiguration = buildConfiguration 46 | } 47 | 48 | /// Get the menu button ID that intersects the drag gesture's touch location. 49 | func getItemID(from location: CGPoint) -> MenuItemID? { 50 | let matchingFrames = frames.filter { $0.value.contains(location) } 51 | 52 | if matchingFrames.count > 1 { 53 | print("[Popovers] Multiple menu items have the same frame. Make sure items don't overlay. If you can't resolve this, please file a bug report (https://github.com/aheze/Popovers/issues).") 54 | } 55 | 56 | if let frame = matchingFrames.first { 57 | return frame.key 58 | } 59 | 60 | return nil 61 | } 62 | 63 | func getDistanceFromMenu(from location: CGPoint) -> CGFloat? { 64 | let menuCenter = CGPoint(x: menuFrame.midX, y: menuFrame.midY) 65 | 66 | /// The location relative to the popover menu's center (0, 0) 67 | let normalizedLocation = CGPoint(x: location.x - menuCenter.x, y: location.y - menuCenter.y) 68 | 69 | if abs(normalizedLocation.y) >= menuFrame.height / 2, abs(normalizedLocation.y) >= abs(normalizedLocation.x) { 70 | /// top and bottom 71 | let distance = abs(normalizedLocation.y) - menuFrame.height / 2 72 | return distance 73 | } else { 74 | /// left and right 75 | let distance = abs(normalizedLocation.x) - menuFrame.width / 2 76 | return distance 77 | } 78 | } 79 | 80 | /// Get the anchor point to scale from. 81 | func getScaleAnchor(from context: Popover.Context) -> UnitPoint { 82 | if case let .absolute(_, popoverAnchor) = context.attributes.position { 83 | return popoverAnchor.unitPoint 84 | } 85 | 86 | return .center 87 | } 88 | } 89 | } 90 | 91 | #endif 92 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Shadow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Shadow.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 2/4/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | public extension Templates { 13 | /// A convenient way to apply shadows. Access using the `.popoverShadow()` modifier. 14 | struct Shadow { 15 | /// The shadow color. 16 | public var color = Color.black.opacity(0.25) 17 | 18 | /// The shadow radius. 19 | public var radius = CGFloat(0) 20 | 21 | /// The shadow's x offset. 22 | public var x = CGFloat(0) 23 | 24 | /// The shadow's y offset. 25 | public var y = CGFloat(0) 26 | 27 | public static var system = Self( 28 | color: Color.black.opacity(0.25), 29 | radius: 40, 30 | x: 0, 31 | y: 4 32 | ) 33 | 34 | public init( 35 | color: Color = Color.black.opacity(0.25), 36 | radius: CGFloat = CGFloat(0), 37 | x: CGFloat = CGFloat(0), 38 | y: CGFloat = CGFloat(0) 39 | ) { 40 | self.color = color 41 | self.radius = radius 42 | self.x = x 43 | self.y = y 44 | } 45 | } 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Templates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Templates.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 12/23/21. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /** 13 | Some templates to get started with Popovers. 14 | 15 | The rest of the files in this folder extend `Templates`. 16 | */ 17 | public enum Templates { 18 | /// Highlight color for the alert and menu buttons. 19 | public static var buttonHighlightColor = Color.secondary.opacity(0.2) 20 | } 21 | #endif 22 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Popover/Sources/Templates/Views.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Views.swift 3 | // Popovers 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 6/14/22. 6 | // Copyright © 2022 A. Zheng. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | public extension Templates { 13 | /// A vertical stack that adds separators 14 | /// From https://movingparts.io/variadic-views-in-swiftui 15 | struct DividedVStack: View { 16 | var leadingMargin: CGFloat 17 | var trailingMargin: CGFloat 18 | var color: UIColor? 19 | var content: Content 20 | 21 | public init( 22 | leadingMargin: CGFloat = 0, 23 | trailingMargin: CGFloat = 0, 24 | color: UIColor? = nil, 25 | @ViewBuilder content: () -> Content 26 | ) { 27 | self.leadingMargin = leadingMargin 28 | self.trailingMargin = trailingMargin 29 | self.color = color 30 | self.content = content() 31 | } 32 | 33 | public var body: some View { 34 | _VariadicView.Tree( 35 | DividedVStackLayout( 36 | leadingMargin: leadingMargin, 37 | trailingMargin: trailingMargin, 38 | color: color 39 | ) 40 | ) { 41 | content 42 | } 43 | } 44 | } 45 | 46 | struct DividedVStackLayout: _VariadicView_UnaryViewRoot { 47 | var leadingMargin: CGFloat 48 | var trailingMargin: CGFloat 49 | var color: UIColor? 50 | 51 | @ViewBuilder 52 | public func body(children: _VariadicView.Children) -> some View { 53 | let last = children.last?.id 54 | 55 | VStack(spacing: 0) { 56 | ForEach(children) { child in 57 | child 58 | 59 | if child.id != last { 60 | Divider() 61 | .opacity(color == nil ? 1 : 0) 62 | .overlay( 63 | color.map { Color($0) } ?? .clear /// If we drop iOS 14 support, we can use `.overlay {}` and add an `if-else` statement here. 64 | ) 65 | .padding(.leading, leadingMargin) 66 | .padding(.trailing, trailingMargin) 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | /// A horizontal stack that adds separators 74 | struct DividedHStack: View { 75 | var topMargin: CGFloat 76 | var bottomMargin: CGFloat 77 | var content: Content 78 | 79 | public init( 80 | topMargin: CGFloat = 0, 81 | bottomMargin: CGFloat = 0, 82 | @ViewBuilder content: () -> Content 83 | ) { 84 | self.topMargin = topMargin 85 | self.bottomMargin = bottomMargin 86 | self.content = content() 87 | } 88 | 89 | public var body: some View { 90 | _VariadicView.Tree( 91 | DividedHStackLayout( 92 | topMargin: topMargin, 93 | bottomMargin: bottomMargin 94 | ) 95 | ) { 96 | content 97 | } 98 | } 99 | } 100 | 101 | struct DividedHStackLayout: _VariadicView_UnaryViewRoot { 102 | var topMargin: CGFloat 103 | var bottomMargin: CGFloat 104 | @ViewBuilder 105 | public func body(children: _VariadicView.Children) -> some View { 106 | let last = children.last?.id 107 | 108 | HStack(spacing: 0) { 109 | ForEach(children) { child in 110 | child 111 | 112 | if child.id != last { 113 | Divider() 114 | .padding(.top, topMargin) 115 | .padding(.bottom, bottomMargin) 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | #endif 123 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/BoardView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BoardView: View { 4 | @EnvironmentObject var envi: LogicEnvironment 5 | var body: some View { 6 | ZStack{ 7 | CanvasContainerView() 8 | .ignoresSafeArea() 9 | .animation(.easeInOut(duration: 0.2), value: envi.board.selectedCircuit) 10 | CircuitView(circuit: envi.selectedCircuit) 11 | }.ignoresSafeArea(.keyboard) 12 | } 13 | } 14 | 15 | struct CircuitView: View { 16 | @EnvironmentObject var envi: LogicEnvironment 17 | @ObservedObject var circuit: Circuit 18 | 19 | var body: some View { 20 | ZStack { 21 | VStack { 22 | ActionControlView(circuit: envi.selectedCircuit) 23 | .padding([.top, .horizontal], 20) 24 | .animation(.easeInOut, value: circuit.state) 25 | Spacer() 26 | if envi.showDock { 27 | HStack { 28 | CircuitPickerView() 29 | .padding([.bottom, .horizontal], 24) 30 | .padding(.leading, 84) 31 | .transition(.opacity) 32 | Spacer() 33 | } 34 | } 35 | } 36 | .animation(.easeInOut, value: envi.showDock) 37 | .animation(.easeInOut, value: envi.board.selectedCircuit) 38 | .animation(.easeInOut, value: envi.board.circuits) 39 | .animation(.easeInOut, value: circuit.state) 40 | 41 | HStack { 42 | SystemPickerView(circuit: circuit) 43 | .frame(maxWidth: 64, alignment: .leading) 44 | .padding(20) 45 | Spacer() 46 | if circuit.state == .add { 47 | DevicePickerView() 48 | .padding([.trailing], 20) 49 | .padding([.vertical], 100) 50 | .transition(.opacity) 51 | } 52 | } 53 | .animation(.easeInOut, value: circuit.state) 54 | 55 | } 56 | } 57 | } 58 | 59 | struct BoardView_Previews: PreviewProvider { 60 | static var previews: some View { 61 | BoardView() 62 | .previewInterfaceOrientation(.portrait) 63 | .environmentObject(LogicEnvironment()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/CanvasScene+Animation.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension SKAction { 4 | static func strokeTransitionColor(of node: SKNode, toColor: UIColor, duration: CGFloat = 0.15) -> SKAction 5 | { 6 | return SKAction.customAction(withDuration: duration, actionBlock: { (node : SKNode!, elapsedTime : CGFloat) -> Void in 7 | guard let node = node as? SKShapeNode else { return } 8 | let fromColor = node.strokeColor 9 | let fraction = CGFloat(elapsedTime / duration) 10 | let startColorComponents = fromColor.toComponents() 11 | let endColorComponents = toColor.toComponents() 12 | let transColor = UIColor(red: lerp(a: startColorComponents.red, b: endColorComponents.red, fraction: fraction), 13 | green: lerp(a: startColorComponents.green, b: endColorComponents.green, fraction: fraction), 14 | blue: lerp(a: startColorComponents.blue, b: endColorComponents.blue, fraction: fraction), 15 | alpha: lerp(a: startColorComponents.alpha, b: endColorComponents.alpha, fraction: fraction)) 16 | node.strokeColor = transColor 17 | } 18 | ) 19 | } 20 | 21 | static func fillTransitionColor(of node: SKNode, toColor: UIColor, duration: CGFloat = 0.15) -> SKAction 22 | { 23 | return SKAction.customAction(withDuration: duration, actionBlock: { (node : SKNode!, elapsedTime : CGFloat) -> Void in 24 | guard let node = node as? SKShapeNode else { return } 25 | let fromColor = node.fillColor 26 | let fraction = CGFloat(elapsedTime / duration) 27 | let startColorComponents = fromColor.toComponents() 28 | let endColorComponents = toColor.toComponents() 29 | let transColor = UIColor(red: lerp(a: startColorComponents.red, b: endColorComponents.red, fraction: fraction), 30 | green: lerp(a: startColorComponents.green, b: endColorComponents.green, fraction: fraction), 31 | blue: lerp(a: startColorComponents.blue, b: endColorComponents.blue, fraction: fraction), 32 | alpha: lerp(a: startColorComponents.alpha, b: endColorComponents.alpha, fraction: fraction)) 33 | node.fillColor = transColor 34 | } 35 | ) 36 | } 37 | } 38 | 39 | extension SKNode { 40 | func strokeColorTransition(to color: UIColor, duration: CGFloat = 0.15) { 41 | self.run(SKAction.strokeTransitionColor(of: self, toColor: color, duration: duration)) 42 | } 43 | 44 | func fillColorTransition(to color: UIColor, duration: CGFloat = 0.15) { 45 | self.run(SKAction.fillTransitionColor(of: self, toColor: color, duration: duration)) 46 | } 47 | 48 | func spriteColorTransition(to color: UIColor, opacity: CGFloat) { 49 | self.run(SKAction.colorize(with: color, colorBlendFactor: opacity, duration: 0.15)) 50 | } 51 | 52 | func opacityTransition(_ opacity: CGFloat) { 53 | self.run(SKAction.fadeAlpha(to: opacity, duration: 0.15)) 54 | } 55 | 56 | func blueTint() { 57 | self.enumerateChildNodes(withName: ".//*") { node, _ in 58 | if node.name?.contains("PIN") ?? false { return } 59 | node.spriteColorTransition(to: .blue, opacity: 0.5) 60 | node.strokeColorTransition(to: .canvasBlue) 61 | node.fillColorTransition(to: (node.parent?.isOccupied() ?? false) ? .canvasBlue : .canvasLightBlue) 62 | } 63 | } 64 | 65 | func removeTint() { 66 | self.enumerateChildNodes(withName: ".//*") { node, _ in 67 | if node.name?.contains("PIN") ?? false { return } 68 | node.spriteColorTransition(to: .blue, opacity: 0) 69 | node.fillColorTransition(to: (node.parent?.isOccupied() ?? false) ? .darkGray : .gray) 70 | guard let scene = self.scene as? CanvasScene, 71 | scene.state != .simulate else { return } 72 | node.strokeColorTransition(to: .darkGray) 73 | 74 | } 75 | } 76 | 77 | func colorOccupied(_ isOccupied: Bool) { 78 | self.enumerateChildNodes(withName: ".//*") { node, _ in 79 | node.fillColorTransition(to: isOccupied ? .darkGray : .gray) 80 | } 81 | } 82 | } 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/CanvasScene+Color.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | // Derived from solutions found in https://stackoverflow.com/questions/35029672/getting-pixel-color-from-an-image-using-cgpoint 4 | 5 | extension CGImage { 6 | func getPixelColor(point: CGPoint) -> CGColor? { 7 | 8 | guard let provider = self.dataProvider, let pixelData = provider.data else { return nil } 9 | let data: UnsafePointer = CFDataGetBytePtr(pixelData) 10 | 11 | let pixelInfo: Int = ((Int(self.width) * Int(point.y)) + Int(point.x)) * 4 12 | let r = CGFloat(data[pixelInfo]) / CGFloat(255.0) 13 | let g = CGFloat(data[pixelInfo+1]) / CGFloat(255.0) 14 | let b = CGFloat(data[pixelInfo+2]) / CGFloat(255.0) 15 | let a = CGFloat(data[pixelInfo+3]) / CGFloat(255.0) 16 | 17 | return CGColor(red: r, green: g, blue: b, alpha: a) 18 | } 19 | } 20 | 21 | func lerp(a : CGFloat, b : CGFloat, fraction : CGFloat) -> CGFloat 22 | { 23 | return (b-a) * fraction + a 24 | } 25 | 26 | // Custom color transition for SKShapeNode derived from solution by OwlOCR and Patrick Collin 27 | // https://stackoverflow.com/questions/20872556/skshapenode-animate-color-change 28 | 29 | struct ColorComponents { 30 | var red = CGFloat.zero 31 | var green = CGFloat.zero 32 | var blue = CGFloat.zero 33 | var alpha = CGFloat.zero 34 | } 35 | 36 | extension UIColor { 37 | func toComponents() -> ColorComponents { 38 | var components = ColorComponents() 39 | getRed(&components.red, green: &components.green, blue: &components.blue, alpha: &components.alpha) 40 | return components 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/CanvasScene+Device.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension CanvasScene { 4 | public func addDevice(_ device: some Device, at position: CGPoint) { 5 | let deviceNode = device.spriteNode 6 | 7 | guard let scene = scene else { return } 8 | deviceNode.position = CGPoint(x: cameraOffset.x + ((position.x - (frame.width / 2)) * previousCameraScale), 9 | y: cameraOffset.y + (((frame.height / 2) - position.y) * previousCameraScale)) 10 | 11 | scene.addChild(deviceNode) 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/CanvasScene+Texture.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension SKTexture { 4 | static let offButtonTexture = SKTexture(imageNamed: "InputFalse.png") 5 | static let onButtonTexture = SKTexture(imageNamed: "InputTrue.png") 6 | static let offDisplayTexture = SKTexture(imageNamed: "OutputFalse.png") 7 | static let onDisplayTexture = SKTexture(imageNamed: "OutputTrue.png") 8 | } 9 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/CanvasScene.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | import UIKit 3 | 4 | public class CanvasScene: SKScene, ObservableObject, UIGestureRecognizerDelegate { 5 | var parentCircuit: Circuit? 6 | 7 | var inited = false 8 | 9 | var previousCameraPoint = CGPoint.zero 10 | var originalCameraPoint = CGPoint.zero 11 | var cameraPoint = CGPoint.zero 12 | var cameraOffset = CGPoint.zero 13 | 14 | var state: BoardState = .add { 15 | didSet { 16 | touchedDeviceNode = nil 17 | touchedWireNode = nil 18 | if state == .simulate { 19 | parentCircuit?.devices.forEach { 20 | $0.pinNodes.forEach { 21 | $0.opacityTransition(0) 22 | } 23 | } 24 | } else if oldValue == .simulate { 25 | parentCircuit?.devices.forEach { 26 | $0.pinNodes.forEach { 27 | $0.removeTint() 28 | $0.opacityTransition(1) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | var touchedDeviceNode: SKSpriteNode? = nil { 36 | didSet { 37 | if touchedDeviceNode == nil { 38 | parentCircuit?.selectedDevice = nil 39 | guard oldValue != draggedDeviceNode else { return } 40 | oldValue?.removeTint() 41 | } else if oldValue != touchedDeviceNode { 42 | guard let parentDevice = touchedDeviceNode?.parentDevice else { return } 43 | parentCircuit?.selectedDevice = parentDevice 44 | oldValue?.removeTint() 45 | touchedDeviceNode?.blueTint() 46 | } 47 | } 48 | } 49 | 50 | var draggedDeviceNode: SKSpriteNode? = nil { 51 | didSet { 52 | touchedDeviceNode = nil 53 | if draggedDeviceNode == nil { 54 | oldValue?.removeTint() 55 | } else { 56 | oldValue?.removeTint() 57 | draggedDeviceNode?.blueTint() 58 | } 59 | } 60 | } 61 | var draggedNodePosition: CGPoint? = nil 62 | 63 | 64 | var touchedWireNode: SKShapeNode? = nil { 65 | didSet { 66 | if touchedWireNode == nil { 67 | parentCircuit?.selectedConnection = nil 68 | guard oldValue != touchedWireNode, state != .simulate else { return } 69 | oldValue?.strokeColorTransition(to: .darkGray) 70 | } else if oldValue != touchedWireNode { 71 | guard let parentConnection = touchedWireNode?.parentConnection else { 72 | return 73 | } 74 | parentCircuit?.selectedConnection = parentConnection 75 | oldValue?.strokeColorTransition(to: .darkGray) 76 | touchedWireNode?.strokeColorTransition(to: .canvasBlue) 77 | } 78 | } 79 | } 80 | 81 | var tempLine: SKShapeNode? 82 | var tempLineLocations: [CGPoint] = [CGPoint]() 83 | var tempLinePointCount: Int = 0 84 | var tempStartPin: SKSpriteNode? 85 | var newPointCreated: Bool = false 86 | 87 | var previousCameraScale: CGFloat = 1.5 88 | 89 | let oneFingerTapRecognizer = UITapGestureRecognizer() 90 | let oneFingerPanRecognizer = UIPanGestureRecognizer() 91 | let pencilPanRecognizer = PencilPanGestureRecognizer() 92 | let twoFingerPanRecognizer = UIPanGestureRecognizer() 93 | let twoFingerPinchRecognizer = UIPinchGestureRecognizer() 94 | 95 | public override func didMove(to view: SKView) { 96 | guard let scene = scene else { return } 97 | 98 | oneFingerTapRecognizer.addTarget(self, action: #selector(tap)) 99 | oneFingerTapRecognizer.delegate = self 100 | oneFingerTapRecognizer.allowedTouchTypes = [0,2,3] 101 | oneFingerTapRecognizer.numberOfTouchesRequired = 1 102 | view.addGestureRecognizer(oneFingerTapRecognizer) 103 | 104 | oneFingerPanRecognizer.addTarget(self, action: #selector(oneFingerPan)) 105 | oneFingerPanRecognizer.delegate = self 106 | oneFingerPanRecognizer.allowedTouchTypes = [0,3] 107 | oneFingerPanRecognizer.minimumNumberOfTouches = 1 108 | oneFingerPanRecognizer.maximumNumberOfTouches = 1 109 | view.addGestureRecognizer(oneFingerPanRecognizer) 110 | 111 | pencilPanRecognizer.canvasScene = self 112 | pencilPanRecognizer.delegate = self 113 | pencilPanRecognizer.allowedTouchTypes = [2] 114 | pencilPanRecognizer.minimumNumberOfTouches = 1 115 | pencilPanRecognizer.maximumNumberOfTouches = 1 116 | pencilPanRecognizer.delaysTouchesBegan = true 117 | view.addGestureRecognizer(pencilPanRecognizer) 118 | 119 | twoFingerPanRecognizer.addTarget(self, action: #selector(twoFingerPan)) 120 | twoFingerPanRecognizer.allowedScrollTypesMask = .continuous 121 | twoFingerPanRecognizer.minimumNumberOfTouches = 2 122 | twoFingerPanRecognizer.maximumNumberOfTouches = 2 123 | twoFingerPanRecognizer.delegate = self 124 | view.addGestureRecognizer(twoFingerPanRecognizer) 125 | 126 | twoFingerPinchRecognizer.addTarget(self, action: #selector(pinch)) 127 | twoFingerPinchRecognizer.delegate = self 128 | view.addGestureRecognizer(twoFingerPinchRecognizer) 129 | 130 | let camera = SKCameraNode() 131 | 132 | if !inited { 133 | camera.position = CGPoint(x: 0, y: 0) 134 | cameraPoint = camera.position 135 | originalCameraPoint = camera.position 136 | camera.setScale(1.5) 137 | inited = true 138 | } else { 139 | camera.position = cameraPoint 140 | } 141 | 142 | scene.camera = camera 143 | self.addChild(camera) 144 | } 145 | 146 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { 147 | if (gestureRecognizer == twoFingerPanRecognizer && otherGestureRecognizer == twoFingerPinchRecognizer) 148 | || (gestureRecognizer == twoFingerPinchRecognizer && otherGestureRecognizer == twoFingerPanRecognizer) { 149 | return true 150 | } else { 151 | return false 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/CanvasView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SpriteKit 3 | 4 | public struct CanvasView: View { 5 | @EnvironmentObject var envi: LogicEnvironment 6 | @ObservedObject var canvasScene: CanvasScene 7 | 8 | public var body: some View { 9 | SpriteView(scene: canvasScene) 10 | .dropDestination(for: String.self) { items, location in 11 | guard let item = items.first else { return false } 12 | 13 | let deviceName = DeviceName.deviceStringToDeviceName(string: item) 14 | var device = deviceName.deviceNameToDevice() 15 | envi.board.selectedCircuit.add(&device, at: location) 16 | return true 17 | } 18 | } 19 | } 20 | 21 | public struct CanvasContainerView: View { 22 | @EnvironmentObject var envi: LogicEnvironment 23 | 24 | public var body: some View { 25 | ZStack { 26 | Rectangle() 27 | .fill(Color(uiColor: UIColor.lightGray)) 28 | if envi.board.circuits.count == 0 { 29 | Text("NO CIRCUIT IS SELECTED") 30 | .font(.system(.body, design: .monospaced, weight: .bold)) 31 | .foregroundColor(.white) 32 | .padding(8) 33 | .padding(.horizontal, 4) 34 | .background(.thinMaterial, in: Capsule()) 35 | } 36 | ForEach(envi.board.circuits) { circuit in 37 | CanvasView(canvasScene: circuit.canvasScene) 38 | .opacity(envi.selectedCircuit == circuit ? 1 : 0) 39 | } 40 | } 41 | .background(Color(uiColor: UIColor.canvasBackground)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/Gestures/Pan+TwoFinger.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension CanvasScene { 4 | @objc func twoFingerPan(recognizer: UIPanGestureRecognizer) { 5 | guard let camera = scene?.camera else { 6 | return 7 | } 8 | 9 | if recognizer.state == .began { 10 | previousCameraPoint = camera.position 11 | } 12 | 13 | let translation = recognizer.translation(in: self.view) 14 | let newPosition = CGPoint( 15 | x: previousCameraPoint.x + translation.x * -1 * previousCameraScale, 16 | y: previousCameraPoint.y + translation.y * previousCameraScale 17 | ) 18 | camera.position = newPosition 19 | cameraPoint = newPosition 20 | cameraOffset = CGPoint( 21 | x: newPosition.x - originalCameraPoint.x, 22 | y: newPosition.y - originalCameraPoint.y 23 | ) 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/Gestures/Pinch.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension CanvasScene { 4 | @objc func pinch(recognizer: UIPinchGestureRecognizer) { 5 | guard let camera = self.camera else { 6 | return 7 | } 8 | if recognizer.state == .began { 9 | previousCameraScale = camera.xScale 10 | } 11 | let newScale = previousCameraScale * 1 / recognizer.scale 12 | guard newScale < 5, newScale > 0.5 else { return } 13 | camera.setScale(newScale) 14 | if recognizer.state == .ended { 15 | previousCameraScale = camera.xScale 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Canvas/Gestures/Tap.swift: -------------------------------------------------------------------------------- 1 | import SpriteKit 2 | 3 | extension CanvasScene { 4 | @objc func tap(recognizer: UITapGestureRecognizer) { 5 | guard let scene = scene else { return } 6 | let touchLocation = recognizer.location(in: self.view) 7 | let location = CGPoint(x: cameraOffset.x + ((touchLocation.x - (frame.width / 2)) * previousCameraScale), 8 | y: cameraOffset.y + (((frame.height / 2) - touchLocation.y) * previousCameraScale)) 9 | if state == .add { 10 | let nodes = self.nodes(at: location) 11 | let imageNodes = nodes 12 | .filter { $0.name?.contains("MAIN") ?? false } 13 | .map { $0 as? SKSpriteNode } 14 | for imageNode in imageNodes { 15 | guard let imageNode = imageNode, 16 | let node = imageNode.parent as? SKSpriteNode, 17 | let image = imageNode.texture?.cgImage() else { continue } 18 | 19 | var nodeTouchedLocation = scene.convert(location, to: imageNode) 20 | nodeTouchedLocation = CGPoint(x: (nodeTouchedLocation.x + imageNode.size.width/2) * 4, 21 | y: (nodeTouchedLocation.y + imageNode.size.height/2) * 4) 22 | 23 | guard let color = image.getPixelColor(point: nodeTouchedLocation), 24 | color.alpha > 0.01 else { continue } 25 | 26 | touchedDeviceNode = touchedDeviceNode == node ? nil : node 27 | return 28 | } 29 | touchedDeviceNode = nil 30 | } 31 | if state == .wire { 32 | let nodes = self.nodes(at: location) 33 | let wireNodes = nodes 34 | .filter { $0.name?.contains("CONNECTION") ?? false } 35 | .map { $0 as? SKShapeNode } 36 | for wireNode in wireNodes { 37 | guard let wireNode = wireNode, 38 | let image = view?.texture(from: wireNode)?.cgImage() else { continue } 39 | let wireFrame = wireNode.frame 40 | let wireTouchedLocation = CGPoint(x: location.x - wireFrame.minX, 41 | y: location.y - wireFrame.minY) 42 | let pixelPosition = CGPoint(x: wireTouchedLocation.x * CGFloat(image.width) / wireFrame.width, 43 | y: CGFloat(image.height) - (wireTouchedLocation.y * CGFloat(image.height) / wireFrame.height)) 44 | 45 | 46 | guard let color = image.getPixelColor(point: pixelPosition), 47 | color.alpha > 0.05 else { continue } 48 | 49 | touchedWireNode = touchedWireNode == wireNode ? nil : wireNode 50 | 51 | return 52 | } 53 | touchedWireNode = nil 54 | } 55 | if state == .simulate { 56 | let nodes = self.nodes(at: location) 57 | 58 | let buttonNode = nodes 59 | .filter { $0.parentDevice is PushButton } 60 | .first 61 | 62 | guard let button = buttonNode?.parentDevice as? PushButton, 63 | let circuit = parentCircuit else { return } 64 | circuit.toggleAndSolve(button) 65 | } 66 | } 67 | 68 | func changeTouchedDeviceNode(to node: SKSpriteNode?) { 69 | touchedDeviceNode = node 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Color/Color.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | extension UIColor { 3 | static let canvasGreen = UIColor(red: 6/255, green: 151/255, blue: 2/255, alpha: 1.00) 4 | static let canvasYellow = UIColor(red: 183/255, green: 138/255, blue: 2/255, alpha: 1.00) 5 | static let canvasBlue = UIColor(red: 0.1, green: 0.1, blue: 0.7, alpha: 1) 6 | static let canvasLightBlue = UIColor(red: 0.4, green: 0.4, blue: 0.8, alpha: 1) 7 | static let canvasBackground = UIColor(red: 0.7, green: 0.7, blue: 0.7, alpha: 1.00) 8 | } 9 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Controls/ActionControlView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UniformTypeIdentifiers 3 | 4 | public struct ActionControlView: View { 5 | @EnvironmentObject var envi: LogicEnvironment 6 | @ObservedObject var circuit: Circuit 7 | @State var showInfo: Bool = false 8 | let wireHelp: String = "To draw a wire, drag from one pin to another pin. To make a turn in the wire, pause momentarily while dragging or, if using an Apple Pencil, press hard on the screen." 9 | let addHelp: String = "To add a device, drag your chosen device from the right panel and drop it to the desire location on the circuit." 10 | 11 | @State var importing = false 12 | @State var exporting = false 13 | @State var showImportErrorAlert = false 14 | @State var showExportErrorAlert = false 15 | @State var showExportSuccessAlert = false 16 | 17 | public var body: some View { 18 | HStack(spacing: 0) { 19 | if circuit.state == .file { 20 | Button { 21 | importing = true 22 | } label: { 23 | Label("Import", systemImage: "square.and.arrow.down") 24 | .labelStyle(ActionLabelStyle()) 25 | } 26 | .buttonStyle(ActionButtonStyle()) 27 | 28 | Divider() 29 | .frame(height: 20) 30 | 31 | Button { 32 | exporting = true 33 | } label: { 34 | Label("Export", systemImage: "square.and.arrow.up") 35 | .labelStyle(ActionLabelStyle()) 36 | }.buttonStyle(ActionButtonStyle()) 37 | 38 | } 39 | if circuit.state == .wire || circuit.state == .add { 40 | if let device = circuit.selectedDevice { 41 | Button { 42 | let position: CGPoint = .init(x: device.spriteNode.position.x + (device.imageNode.size.width * 1.25), 43 | y: device.spriteNode.position.y) 44 | 45 | var newDevice: any Device = DeviceName.deviceStringToDeviceName(string: device.deviceName).deviceNameToDevice() 46 | circuit.add(&newDevice, at: position) 47 | 48 | newDevice.spriteNode.position = position 49 | } label: { 50 | Label("Duplicate", systemImage: "plus.square.on.square") 51 | .labelStyle(ActionLabelStyle()) 52 | } 53 | .buttonStyle(ActionButtonStyle()) 54 | Divider() 55 | .frame(height: 20) 56 | Button { 57 | circuit.remove(device) 58 | circuit.selectedDevice = nil 59 | circuit.canvasScene.touchedDeviceNode = nil 60 | } label: { 61 | Label("Delete", systemImage: "trash") 62 | .labelStyle(ActionLabelStyle()) 63 | } 64 | .buttonStyle(ActionButtonStyle()) 65 | } else if let connection = circuit.selectedConnection { 66 | Button { 67 | circuit.remove(connection) 68 | circuit.selectedConnection = nil 69 | circuit.canvasScene.touchedWireNode = nil 70 | } label: { 71 | Label("Delete", systemImage: "trash") 72 | .labelStyle(ActionLabelStyle()) 73 | } 74 | .buttonStyle(ActionButtonStyle()) 75 | } else { 76 | Button { 77 | showInfo = !showInfo 78 | } label: { 79 | Label("Help", systemImage: "questionmark.circle") 80 | .labelStyle(ActionLabelStyle()) 81 | } 82 | .buttonStyle(ActionButtonStyle()) 83 | .popover(present: $showInfo) 84 | { 85 | Text(circuit.state == .wire ? wireHelp : addHelp) 86 | .frame(maxWidth: 300) 87 | .fixedSize(horizontal: false, vertical: true) 88 | .lineLimit(4) 89 | .font(.system(size: 13, weight: .regular, design: .default)) 90 | .multilineTextAlignment(.leading) 91 | .padding(20) 92 | .background(.thinMaterial , in: RoundedRectangle(cornerRadius: 12)) 93 | .padding(.top, 20) 94 | } 95 | } 96 | } 97 | 98 | if circuit.state == .simulate { 99 | Button { 100 | circuit.perLevelSolve() 101 | } label: { 102 | Label("Next Step", systemImage: "forward") 103 | .labelStyle(ActionLabelStyle()) 104 | } 105 | .buttonStyle(ActionButtonStyle()) 106 | Divider() 107 | .frame(height: 20) 108 | Button { 109 | circuit.hardReset() 110 | } label: { 111 | Label("Reset", systemImage: "gobackward") 112 | .labelStyle(ActionLabelStyle()) 113 | } 114 | .buttonStyle(ActionButtonStyle()) 115 | } 116 | 117 | } 118 | 119 | .background(.thinMaterial, in: Capsule()) 120 | .clipShape(Capsule()) 121 | .transaction { transaction in 122 | transaction.animation = .easeInOut 123 | } 124 | .fileImporter( 125 | isPresented: $importing, 126 | allowedContentTypes: [.circ, .json, .init(filenameExtension: "circ")!] 127 | ) { result in 128 | do { 129 | let url: URL = try result.get() 130 | let _ = url.startAccessingSecurityScopedResource() 131 | let jsonData = try Data(contentsOf: url) 132 | url.stopAccessingSecurityScopedResource() 133 | let decoder = JSONDecoder() 134 | let circuitData = try decoder.decode(CircuitData.self, from: jsonData) 135 | let newCircuit = circuitData.makeCircuit() 136 | envi.board.addCircuit(newCircuit) 137 | envi.selectedCircuit = newCircuit 138 | } catch { 139 | showImportErrorAlert = true 140 | } 141 | } 142 | .fileExporter(isPresented: $exporting, 143 | document: CircuitDocument(circuitData: circuit.makeCircuitData()), 144 | contentType: .circ, 145 | defaultFilename: circuit.name + ".circ", 146 | onCompletion: { result in 147 | switch result { 148 | case .success: 149 | showExportSuccessAlert = true 150 | case .failure: 151 | showExportErrorAlert = true 152 | } 153 | }) 154 | .alert("Unable to import .circ file.", isPresented: $showImportErrorAlert) { 155 | Button("OK", role: .cancel) { 156 | showImportErrorAlert = false 157 | } 158 | } 159 | .alert("Unable to export .circ file.", isPresented: $showExportErrorAlert) { 160 | Button("OK", role: .cancel) { 161 | showExportErrorAlert = false 162 | } 163 | } 164 | .alert("The .circ file was exported successfully.", isPresented: $showExportSuccessAlert) { 165 | Button("OK", role: .cancel) { 166 | showExportSuccessAlert = false 167 | } 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Pickers/CircuitPickerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct CircuitPickerView: View { 4 | @EnvironmentObject var envi: LogicEnvironment 5 | 6 | public var body: some View { 7 | ScrollView(.horizontal, showsIndicators: false) { 8 | HStack(spacing: 8) { 9 | ForEach($envi.board.circuits) { $circuit in 10 | CircuitPickerUnitView(circuit: $circuit) 11 | } 12 | Button { 13 | envi.board.addCircuit() 14 | } label: { 15 | Label("Add Circuit", systemImage: "plus") 16 | .labelStyle(.iconOnly) 17 | .font(.system(size: 20, weight: .bold)) 18 | } 19 | .buttonStyle(CircleButtonStyle()) 20 | 21 | } 22 | .padding(12) 23 | .background(.ultraThinMaterial) 24 | .clipShape(RoundedRectangle(cornerRadius: 40)) 25 | } 26 | .clipShape(RoundedRectangle(cornerRadius: 40)) 27 | } 28 | } 29 | 30 | 31 | struct CircuitPickerUnitView: View { 32 | @EnvironmentObject var envi: LogicEnvironment 33 | @Binding var circuit: Circuit 34 | @State var showRename: Bool = false 35 | @State var newName: String = "" 36 | @State var showDelete: Bool = false 37 | 38 | var body: some View { 39 | Button { 40 | envi.selectedCircuit = circuit 41 | } label: { 42 | Text(circuit.name.uppercased()) 43 | .font(.system(size: 16, weight: .bold, design: .monospaced)) 44 | .lineLimit(1) 45 | .fixedSize() 46 | 47 | } 48 | .buttonStyle(CircuitButtonStyle(isChosen: envi.selectedCircuit == circuit)) 49 | .contextMenu(menuItems: { 50 | Button { 51 | showRename = true 52 | newName = circuit.name 53 | } label: { 54 | Label("Rename", systemImage: "text.cursor") 55 | }.disabled(circuit.isExample) 56 | Button(role: .destructive) { 57 | showDelete = true 58 | } label: { 59 | Label("Delete", systemImage: "trash") 60 | }.disabled(circuit.isExample) 61 | }) 62 | .shadow(color: Color(red: 0.0, green: 0.4, blue: 1.0).opacity(envi.selectedCircuit == circuit ? 0.3 : 0), 63 | radius: 10, 64 | x: 5, 65 | y: -5) 66 | .shadow(color: .black.opacity(envi.selectedCircuit == circuit ? 0.5 : 0), radius: envi.selectedCircuit == circuit ? 8 : 0, x: -4, y: 4) 67 | .alert("Rename Circuit", isPresented: $showRename, actions: { 68 | TextField(circuit.name, text: $newName) 69 | Button("Cancel", role: .cancel) { 70 | showRename = false 71 | } 72 | Button("Rename") { 73 | circuit.name = newName 74 | newName = "" 75 | showRename = false 76 | } 77 | }, message: { 78 | Text("Enter the new name for " + circuit.name) 79 | }) 80 | .alert("Delete Circuit", isPresented: $showDelete, actions: { 81 | Button("Cancel", role: .cancel) { 82 | showDelete = false 83 | } 84 | Button("Delete", role: .destructive) { 85 | envi.board.removeCircuit(circuit) 86 | envi.selectedCircuit = envi.board.mainCircuit 87 | showDelete = false 88 | } 89 | }, message: { 90 | Text("Are you sure you want to delete " + circuit.name + "?") 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Pickers/DevicePickerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct DevicePickerView: View { 4 | var deviceNames: [DeviceName] = [.PUSHBUTTON, .DISPLAY, .AND, .NAND, .OR, .NOR, .XOR, .XNOR, .NOT, .BUFFER] 5 | 6 | public var body: some View { 7 | ScrollView(.vertical ,showsIndicators: false) { 8 | VStack{ 9 | ForEach(deviceNames) { deviceName in 10 | DeviceView(deviceName: deviceName) 11 | } 12 | } 13 | .frame(width: 100) 14 | .padding(10) 15 | } 16 | .background(.ultraThinMaterial) 17 | .clipShape(RoundedRectangle(cornerRadius: 20)) 18 | .frame(maxHeight: 800) 19 | } 20 | } 21 | 22 | struct DeviceView: View { 23 | var deviceName: DeviceName 24 | 25 | var body: some View { 26 | VStack { 27 | Image(deviceName.imageName) 28 | .resizable() 29 | .aspectRatio(contentMode: .fit) 30 | Text(deviceName.deviceName) 31 | .lineLimit(1) 32 | .foregroundColor(.white) 33 | .font(.system(size: 12, weight: .bold, design: .monospaced)) 34 | .padding(4) 35 | .padding(.horizontal, 4) 36 | .background(Color(red: 0.4, green: 0.4, blue: 0.4), in: RoundedRectangle(cornerRadius: 10)) 37 | } 38 | .padding(8) 39 | .background(Color(red: 0.25, green: 0.25, blue: 0.25), in: RoundedRectangle(cornerRadius: 12)) 40 | .draggable(deviceName.deviceName) { 41 | Image(deviceName.imageName) 42 | .resizable() 43 | .aspectRatio(contentMode: .fit) 44 | .frame(maxWidth: 100, maxHeight: 100) 45 | } 46 | .hoverEffect(.lift) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/Pickers/SystemPickerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SystemPickerView: View { 4 | @EnvironmentObject var envi: LogicEnvironment 5 | @ObservedObject var circuit: Circuit 6 | var body: some View { 7 | VStack() { 8 | Spacer() 9 | VStack() { 10 | Spacer() 11 | VStack { 12 | Button { 13 | circuit.state = .file 14 | } label: { 15 | Label("File Picker", systemImage: "doc.fill") 16 | .labelStyle(.iconOnly) 17 | .imageScale(.large) 18 | .font(.system(size: 20)) 19 | } 20 | .buttonStyle(SystemButtonStyle(isChosen: circuit.state == .file)) 21 | .keyboardShortcut("s", modifiers: .command) 22 | 23 | Button { 24 | circuit.state = .add 25 | } label: { 26 | Label("Add", systemImage: "memorychip") 27 | .rotationEffect(.degrees(90)) 28 | .labelStyle(.iconOnly) 29 | .imageScale(.large) 30 | .font(.system(size: 20, weight: .bold)) 31 | 32 | } 33 | .buttonStyle(SystemButtonStyle(isChosen: circuit.state == .add)) 34 | .keyboardShortcut("a", modifiers: .command) 35 | 36 | Button { 37 | circuit.state = .wire 38 | } label: { 39 | Label("Wire", systemImage: "app.connected.to.app.below.fill") 40 | .rotationEffect(.degrees(90)) 41 | .labelStyle(.iconOnly) 42 | .imageScale(.large) 43 | .font(.system(size: 20, weight: .bold)) 44 | } 45 | .keyboardShortcut("w", modifiers: .command) 46 | .buttonStyle(SystemButtonStyle(isChosen: circuit.state == .wire)) 47 | 48 | Button { 49 | circuit.state = .simulate 50 | } label: { 51 | Label("Simulate", systemImage: "play.fill") 52 | .labelStyle(.iconOnly) 53 | .imageScale(.large) 54 | .font(.system(size: 20)) 55 | } 56 | .buttonStyle(SystemButtonStyle(isChosen: circuit.state == .simulate)) 57 | .keyboardShortcut(.defaultAction) 58 | 59 | } 60 | .padding(10) 61 | .background(.ultraThinMaterial) 62 | .clipShape(RoundedRectangle(cornerSize: .init(width: 100, height: 100))) 63 | .frame(alignment: .leading) 64 | 65 | 66 | Spacer() 67 | 68 | Button { 69 | envi.showDock.toggle() 70 | } label: { 71 | Label("", systemImage: "rectangle.stack.fill") 72 | .labelStyle(.iconOnly) 73 | .imageScale(.large) 74 | .font(.system(size: 20)) 75 | } 76 | .buttonStyle(SystemButtonStyle(isChosen: envi.showDock)) 77 | .keyboardShortcut("b") 78 | .padding(10) 79 | .background(.ultraThinMaterial) 80 | .clipShape(RoundedRectangle(cornerSize: .init(width: 100, height: 100))) 81 | .frame(alignment: .bottomLeading) 82 | 83 | } 84 | } 85 | } 86 | } 87 | 88 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/ViewStyles/ButtonStyles/ActionButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ActionButtonStyle: ButtonStyle { 4 | public func makeBody(configuration: Configuration) -> some View { 5 | configuration.label 6 | .foregroundColor(.primary) 7 | .hoverEffect(.highlight) 8 | .padding(4) 9 | .padding(.horizontal, 4) 10 | .background( 11 | Capsule() 12 | .fill(configuration.isPressed ? Color.primary.opacity(0.3) : Color.clear) 13 | ) 14 | .padding(4) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/ViewStyles/ButtonStyles/CircleButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct CircleButtonStyle: ButtonStyle { 4 | public func makeBody(configuration: Configuration) -> some View { 5 | configuration.label 6 | .foregroundColor(.white) 7 | .frame(width: 36, height: 36) 8 | 9 | .foregroundColor(.white) 10 | .background( 11 | Circle() 12 | .fill(configuration.isPressed ? Color(red: 0.4, green: 0.4, blue: 0.4) : Color.clear) 13 | .offset(x: 12, y:-12) 14 | .blur(radius: 12) 15 | 16 | ) 17 | .background(Color(red: 0.25, green: 0.25, blue: 0.25)) 18 | .clipShape(Circle()) 19 | .hoverEffect(.lift) 20 | .shadow(color: .black.opacity(configuration.isPressed ? 0.5 : 0), radius: configuration.isPressed ? 6 : 0, x: -4, y: 4) 21 | .transaction { transaction in 22 | transaction.animation = nil 23 | } 24 | .animation(.default, value: configuration.isPressed) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/ViewStyles/ButtonStyles/CircuitButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct CircuitButtonStyle: ButtonStyle { 4 | public var isChosen: Bool 5 | public func makeBody(configuration: Configuration) -> some View { 6 | configuration.label 7 | .padding([.horizontal], 8) 8 | .padding(8) 9 | .foregroundColor(.white) 10 | .background( 11 | RoundedRectangle(cornerRadius: 12) 12 | .fill(isChosen ? Color(red: 0.0, green: 0.52, blue: 1.0) : Color.clear) 13 | .offset(x: 12, y:-12) 14 | .blur(radius: 16) 15 | ) 16 | .background(isChosen ? Color(red: 0.0, green: 0.14, blue: 1.0) : Color(red: 0.25, green: 0.25, blue: 0.25)) 17 | .clipShape(RoundedRectangle(cornerRadius: 32)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/ViewStyles/ButtonStyles/NoFlickerButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct NoFlickerButtonStyle: ButtonStyle { 4 | public func makeBody(configuration: Configuration) -> some View { 5 | configuration.label 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/ViewStyles/ButtonStyles/SystemButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct SystemButtonStyle: ButtonStyle { 4 | public var isChosen: Bool 5 | 6 | public func makeBody(configuration: Configuration) -> some View { 7 | configuration.label 8 | .frame(width: 32, height: 32, alignment: .center) 9 | .aspectRatio(1.0, contentMode: .fit) 10 | .padding(8) 11 | .foregroundColor(.white) 12 | .background( 13 | Circle() 14 | .fill(isChosen ? Color(red: 0.0, green: 0.52, blue: 1.0) : Color.clear) 15 | .offset(x: 12, y:-12) 16 | .blur(radius: 12) 17 | 18 | ) 19 | .background(isChosen ? Color(red: 0.0, green: 0.14, blue: 1.0) : Color(red: 0.25, green: 0.25, blue: 0.25)) 20 | .clipShape(Circle()) 21 | .animation(.easeIn, value: isChosen) 22 | .shadow(color: Color(red: 0.0, green: 0.4, blue: 1.0).opacity(isChosen ? 0.3 : 0), 23 | radius: 10, 24 | x: 5, 25 | y: -5) 26 | .shadow(color: .black.opacity(isChosen ? 0.5 : 0), 27 | radius: 8, 28 | x: -4, 29 | y: 4) 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LogicBoard.swiftpm/Views/ViewStyles/LabelStyles/ActionLabelStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ActionLabelStyle: LabelStyle { 4 | public func makeBody(configuration: Configuration) -> some View { 5 | HStack { 6 | configuration.icon 7 | .font(.system(size: 16, weight: .regular, design: .default)) 8 | .frame(width: 12, height: 12) 9 | .padding(4) 10 | .padding(.leading, 2) 11 | 12 | configuration.title 13 | .fixedSize() 14 | .lineLimit(1) 15 | .font(.system(size: 13, weight: .regular, design: .default)) 16 | .padding(.trailing, 4) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LogicBoard 2 | 3 | ![LogicBoard Banner](https://github.com/devjoseadolfo/LogicBoard/blob/ab39a8a65ac80308e5b634f710e3cceebf828a12/Banner.jpg) 4 | 5 | ## Introduction 6 | 7 | Hello, welcome to Jose Adolfo Talactac's submission for WWDC23 Swift Student Challenge, LogicBoard! LogicBoard is an interactive sandbox experience that allows you to design and simulate digital logic circuit. LogicBoard is written entirely in Swift and built with SwiftUI, UIKit, and SpriteKit. 8 | 9 | ## Credits 10 | 11 | - [IBM Plex font](https://github.com/IBM/plex) designed by Mike Abbink, Paul van der Laan, and Pieter van Rosmalen for IBM is used in the Playground App's thumbnail and the logic gate image assets. The font is used under the [SIL Open Font License 1.1](https://github.com/IBM/plex/blob/master/LICENSE.txt). 12 | - [Popovers](https://github.com/aheze/Popovers) by Andrew Zheng was used to make a popover modal consistent for all view sizes. The package is used under the [MIT license](https://github.com/aheze/Popovers/blob/main/LICENSE). 13 | - San Francisco font, SF Symbols icons, and the Apple platforms development frameworks used in this Playground App is accessed and used fairly within the license set by Apple Inc. for the development of software for Apple platforms. 14 | - All assets that aren’t attributed to another party are made by the creator of this Playground App, Jose Adolfo Talactac, including app icon, Playground thumbnail, and the app image assets. 15 | 16 | ## Screenshots 17 | ### Adding Devices 18 | ![Screenshot1](https://github.com/devjoseadolfo/LogicBoard/blob/25ca67259c463d6e16f99ecbc707537b6f484c34/Screenshots/Screenshot1.gif) 19 | ### Drawing Wires 20 | ![Screenshot2](https://github.com/devjoseadolfo/LogicBoard/blob/25ca67259c463d6e16f99ecbc707537b6f484c34/Screenshots/Screenshot2.gif) 21 | ### Simulating the Circuit 22 | ![Screenshot3](https://github.com/devjoseadolfo/LogicBoard/blob/25ca67259c463d6e16f99ecbc707537b6f484c34/Screenshots/Screenshot3.gif) 23 | -------------------------------------------------------------------------------- /Screenshots/Screenshot1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/Screenshots/Screenshot1.gif -------------------------------------------------------------------------------- /Screenshots/Screenshot2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/Screenshots/Screenshot2.gif -------------------------------------------------------------------------------- /Screenshots/Screenshot3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devjoseadolfo/LogicBoard/aa048e2df544cc75a287c67276ddf87e7fa08cfd/Screenshots/Screenshot3.gif --------------------------------------------------------------------------------