├── .tuist-version ├── .gitattributes ├── draw.mp4 ├── AutomataEditor ├── Assets.xcassets │ ├── Contents.json │ ├── Menu.imageset │ │ ├── Menu.png │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Icon-App-1024x1024@1x.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Empty.swift ├── IDFactory │ ├── IDFactoryLive.swift │ └── IDFactory.swift ├── ShapeService │ ├── ShapeServiceLive.swift │ └── ShapeService.swift ├── FlexibleView │ ├── SizeReader.swift │ ├── _FlexibleView.swift │ └── FlexibleView.swift ├── AutomatonDocument.swift ├── View+If.swift ├── AutomataEditor.entitlements ├── AutomataEditorApp.swift ├── EditorButton.swift ├── AutomatonState.swift ├── Stroke.swift ├── Overview │ ├── Image+OverviewItemStyle.swift │ ├── HelpView.swift │ ├── OverviewGrid.swift │ ├── OverviewView.swift │ └── OverviewStore.swift ├── Editor │ ├── AddTransitionView.swift │ ├── ToastView.swift │ ├── AutomatonInput.swift │ ├── EditorToolbar.swift │ ├── EditorView.swift │ ├── AutomatonStatesView.swift │ ├── TransitionsView.swift │ └── EditorStore.swift ├── AutomataLibraryService │ ├── AutomataLibraryService.swift │ └── AutomataLibraryServiceLive.swift ├── DocumentPicker.swift ├── CGPoint+Extra.swift ├── AutomatonDocumentService │ ├── AutomatonDocumentService.swift │ └── AutomatonDocumentServiceLive.swift ├── AutomataClassifierService │ ├── AutomataClassifierServiceLive.swift │ └── AutomataClassifierService.swift ├── UIImage+Extra.swift ├── Info.plist ├── CanvasView.swift ├── AutomatonTransition.swift └── CGPoint+Shape.swift ├── Resources └── automata-editor-example.png ├── alib2algo.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2algo.framework │ │ ├── Info.plist │ │ ├── alib2algo │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2algo.framework │ ├── Info.plist │ ├── alib2algo │ └── _CodeSignature │ └── CodeResources ├── alib2data.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2data.framework │ │ ├── Info.plist │ │ ├── alib2data │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2data.framework │ ├── Info.plist │ ├── alib2data │ └── _CodeSignature │ └── CodeResources ├── alib2std.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2std.framework │ │ ├── Info.plist │ │ ├── alib2std │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2std.framework │ ├── Info.plist │ ├── alib2std │ └── _CodeSignature │ └── CodeResources ├── alib2str.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2str.framework │ │ ├── Info.plist │ │ ├── alib2str │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2str.framework │ ├── Info.plist │ ├── alib2str │ └── _CodeSignature │ └── CodeResources ├── alib2xml.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2xml.framework │ │ ├── Info.plist │ │ ├── alib2xml │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2xml.framework │ ├── Info.plist │ ├── alib2xml │ └── _CodeSignature │ └── CodeResources ├── alib2common.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2common.framework │ │ ├── Info.plist │ │ ├── alib2common │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2common.framework │ ├── Info.plist │ ├── alib2common │ └── _CodeSignature │ └── CodeResources ├── alib2measure.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2measure.framework │ │ ├── Info.plist │ │ ├── alib2measure │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2measure.framework │ ├── Info.plist │ ├── alib2measure │ └── _CodeSignature │ └── CodeResources ├── alib2abstraction.xcframework ├── Info.plist ├── ios-arm64 │ └── alib2abstraction.framework │ │ ├── Info.plist │ │ ├── alib2abstraction │ │ └── _CodeSignature │ │ └── CodeResources └── ios-x86_64-simulator │ └── alib2abstraction.framework │ ├── Info.plist │ ├── alib2abstraction │ └── _CodeSignature │ └── CodeResources ├── Tuist ├── Config.swift ├── Dependencies.swift └── Dependencies │ └── Lockfiles │ └── Package.resolved ├── .gitmodules ├── SwiftAutomataLibrary ├── SwiftAutomataLibrary-BridgingHeader.h ├── AutomatonRunResult.m ├── NFA.h ├── Transition.m ├── AutomatonRunResult.h ├── Transition.swift ├── Transition.h ├── Info.plist ├── NFA.swift └── NFA.mm ├── README.md ├── AutomataEditorTests ├── Info.plist ├── OverviewTests │ └── OverviewTests.swift └── EditorTests │ └── EditorTests.swift ├── .gitignore ├── .package.resolved └── Project.swift /.tuist-version: -------------------------------------------------------------------------------- 1 | 3.14.0 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.xcframework/** filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /draw.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fortmarek/automata-editor/HEAD/draw.mp4 -------------------------------------------------------------------------------- /AutomataEditor/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Resources/automata-editor-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fortmarek/automata-editor/HEAD/Resources/automata-editor-example.png -------------------------------------------------------------------------------- /AutomataEditor/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AutomataEditor/Assets.xcassets/Menu.imageset/Menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fortmarek/automata-editor/HEAD/AutomataEditor/Assets.xcassets/Menu.imageset/Menu.png -------------------------------------------------------------------------------- /alib2algo.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0f9624fb32abb9999e44bdf58e5456cf9694eb6181bcfdf7446036c9132a879c 3 | size 1013 4 | -------------------------------------------------------------------------------- /alib2data.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4d4e37d88e7d0e64313c12cbb7faea0e7d40dfe8f287d654d5ac18bf262e8941 3 | size 1013 4 | -------------------------------------------------------------------------------- /alib2std.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:24651dd7131128eb9c821e4e93d8e46c78da09aaa7a38d2bcaf25f1677cb1843 3 | size 1011 4 | -------------------------------------------------------------------------------- /alib2str.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:175c691448b2524e2388b518552f0291b68743ffc1f19496e37850e2f2a51ae4 3 | size 1011 4 | -------------------------------------------------------------------------------- /alib2xml.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f85eef275c1eb5569c1d8c0a5230f1a5296f4b89fa3f952c5697a3c05e4946e2 3 | size 1011 4 | -------------------------------------------------------------------------------- /alib2common.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:23f1c8208c12fde5d6f641b9d363350eb82bdba3e581fcb2e1d1f6b9d163a98f 3 | size 1017 4 | -------------------------------------------------------------------------------- /alib2measure.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:73c984baad252992383ba5368234dffeca8789fecfe04903d2dd358e3b80dcd9 3 | size 1019 4 | -------------------------------------------------------------------------------- /alib2abstraction.xcframework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d72ade9d524c694129792a5f8eb23f8850b82b1c02eb74ced7cb596dee876721 3 | size 1027 4 | -------------------------------------------------------------------------------- /alib2algo.xcframework/ios-arm64/alib2algo.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8d63fcfd9d16046dc65f4a4f5cb6d7e7c49698187a1a68d67ab57cfa59cfab86 3 | size 802 4 | -------------------------------------------------------------------------------- /alib2data.xcframework/ios-arm64/alib2data.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5da1c839a867a214aa1edb567fdcab51264a4f64fa6ef325efbfa3d31be7929d 3 | size 802 4 | -------------------------------------------------------------------------------- /alib2std.xcframework/ios-arm64/alib2std.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1d9429d805d9c95948b190eb3c00f6e270e40bb2b99a4c4c5d737485e4396f47 3 | size 800 4 | -------------------------------------------------------------------------------- /alib2std.xcframework/ios-arm64/alib2std.framework/alib2std: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:33b56f72a484ba20c0b67ccb7b57ff2ed91cf738f164ec20157ceb4ff60713ea 3 | size 1211808 4 | -------------------------------------------------------------------------------- /alib2str.xcframework/ios-arm64/alib2str.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:891eb670c4dd2961b8d625112f238ab7e9ce864abd7f9d34a5a42ab888041b90 3 | size 800 4 | -------------------------------------------------------------------------------- /alib2xml.xcframework/ios-arm64/alib2xml.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c63cf290e97ad178495e56977ebad20854ed6931831f9e2ebd687918f9ccc1a6 3 | size 800 4 | -------------------------------------------------------------------------------- /alib2xml.xcframework/ios-arm64/alib2xml.framework/alib2xml: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:75889c7aeae8fe96496f46ca790f6aea17f4cda7602071dca63f701ed94b5707 3 | size 47516624 4 | -------------------------------------------------------------------------------- /Tuist/Config.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let config = Config( 4 | cloud: .cloud(projectId: "marekfort/automata-editor", url: "https://cloud.tuist.io", options: [.optional, .analytics]) 5 | ) -------------------------------------------------------------------------------- /alib2algo.xcframework/ios-arm64/alib2algo.framework/alib2algo: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:effe9cefaa78f6721150a11950bc4a6e6d6fb31d1fdb496fdc08c98f0d4de02d 3 | size 938631760 4 | -------------------------------------------------------------------------------- /alib2common.xcframework/ios-arm64/alib2common.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:66135eec65fea01b39f4ecbf7b26d36404c751829e0ec96171b9e05592dba562 3 | size 806 4 | -------------------------------------------------------------------------------- /alib2data.xcframework/ios-arm64/alib2data.framework/alib2data: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b4dfaacfe0373c5261f7429e6dd2fb72dc77783b56cdc2e2939236272c11f422 3 | size 463784080 4 | -------------------------------------------------------------------------------- /alib2measure.xcframework/ios-arm64/alib2measure.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:83a1227c4403527506a5ef870c4a8a6b4abdcb9e997e94a1a768aff63cd83888 3 | size 808 4 | -------------------------------------------------------------------------------- /alib2str.xcframework/ios-arm64/alib2str.framework/alib2str: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c2c52c876ba63601c493b5435a0381611e02349f2bcc03e5d691dc155230810f 3 | size 117271088 4 | -------------------------------------------------------------------------------- /AutomataEditor/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fortmarek/automata-editor/HEAD/AutomataEditor/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /AutomataEditor/Empty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Empty.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 30.04.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Empty: Equatable {} 11 | -------------------------------------------------------------------------------- /alib2algo.xcframework/ios-x86_64-simulator/alib2algo.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0a5c07abcda3ed8248d3cec1561e2eddc929567699c957089a6570546d76d67b 3 | size 782 4 | -------------------------------------------------------------------------------- /alib2common.xcframework/ios-arm64/alib2common.framework/alib2common: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1772b32f4f97b34a57ec3a0301eb8bc3964a358aabf006c7926ab24a02e62e1c 3 | size 24608592 4 | -------------------------------------------------------------------------------- /alib2data.xcframework/ios-x86_64-simulator/alib2data.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f0141578849d42aa1bb076f4c9134325e037d5d0298d180cc0949189967946d7 3 | size 782 4 | -------------------------------------------------------------------------------- /alib2std.xcframework/ios-x86_64-simulator/alib2std.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fd3f5c392b9443ee71ca0396059808b524f45738a60b1f0e8615f1b7695566b6 3 | size 780 4 | -------------------------------------------------------------------------------- /alib2std.xcframework/ios-x86_64-simulator/alib2std.framework/alib2std: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6ea1da6ad146d9bb6f106c96ac7a148ab6dc7043d7f5ddb3a53ba1c659ca5e0e 3 | size 552448 4 | -------------------------------------------------------------------------------- /alib2str.xcframework/ios-x86_64-simulator/alib2str.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:aa323b3127a1c4de658dd5ddb11fb9fc8c56446fcac2e30ff89f179ecfd86ef9 3 | size 780 4 | -------------------------------------------------------------------------------- /alib2xml.xcframework/ios-x86_64-simulator/alib2xml.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d5f43d512354458341f7085c6c249ea42568437e1bd91c7c9ef58c66f9e720d9 3 | size 780 4 | -------------------------------------------------------------------------------- /alib2abstraction.xcframework/ios-arm64/alib2abstraction.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:2dc55873188e3187811d9604975fa772698df75be74e520a6a3cabc9237512f5 3 | size 818 4 | -------------------------------------------------------------------------------- /alib2algo.xcframework/ios-x86_64-simulator/alib2algo.framework/alib2algo: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:cdfd4d0133b2b97e0c6cba1f697c30a19827ec946ffb072eb9e28506dff80fac 3 | size 331655328 4 | -------------------------------------------------------------------------------- /alib2common.xcframework/ios-x86_64-simulator/alib2common.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d17a07abc6b60db4abf9e684459f93879a3225a537fe3bb88d479d5693becca8 3 | size 786 4 | -------------------------------------------------------------------------------- /alib2data.xcframework/ios-x86_64-simulator/alib2data.framework/alib2data: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d41ceb0b7c13acc8f3a503ae23af0cfa4cdc224262cad19bb2c91cfea6596f5c 3 | size 168752976 4 | -------------------------------------------------------------------------------- /alib2measure.xcframework/ios-arm64/alib2measure.framework/alib2measure: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8fe72012a8721862b3b4e0abf7669655e55f8b9d9f912ef77a74819a16ae5246 3 | size 1593712 4 | -------------------------------------------------------------------------------- /alib2str.xcframework/ios-x86_64-simulator/alib2str.framework/alib2str: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:7d8fbaf8208ed632c34f5621c3184d378bc64ec8784d45665dbd46d446a2f121 3 | size 36324672 4 | -------------------------------------------------------------------------------- /alib2xml.xcframework/ios-x86_64-simulator/alib2xml.framework/alib2xml: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0e40ce827b6c615e490c54535c7c99f4e4e5c697a3834bb5af5e23a4c1d720f0 3 | size 16439648 4 | -------------------------------------------------------------------------------- /alib2algo.xcframework/ios-arm64/alib2algo.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6f243f730f96caf32994394d6c32423cfc5ce676f3cd381e5eeb3dd8f02eeacf 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2common.xcframework/ios-x86_64-simulator/alib2common.framework/alib2common: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:57648fae55d198a11e38cf4cea184b9f80b5392c882a74212c9b4cdb2593f334 3 | size 10821664 4 | -------------------------------------------------------------------------------- /alib2data.xcframework/ios-arm64/alib2data.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:2f9f20468f936dc5b3092770a0f32e50437334ecad25ac5fb170fb67e7e4dc09 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2measure.xcframework/ios-x86_64-simulator/alib2measure.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b8f7b32d318cdfeaf1ce5235a14fb766f40e7648a6c2543b336f9f170124f9cc 3 | size 788 4 | -------------------------------------------------------------------------------- /alib2std.xcframework/ios-arm64/alib2std.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6d22233b1ec7c4fc38772e4bf0d6ed422d8b643ace3615765ff5ef8870dc1d74 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2str.xcframework/ios-arm64/alib2str.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3f45e413655d37c4f9026384a3bead0ce44d55661a4ab2a797660be6750b0698 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2xml.xcframework/ios-arm64/alib2xml.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:7378ccb78ebca452041a4349e167132b284c30776f6788d970c24e360612efc1 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2abstraction.xcframework/ios-arm64/alib2abstraction.framework/alib2abstraction: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c132bf37bb9ee358b057748b1c11d19a742476859514b39089fd5e6022e03816 3 | size 7151424 4 | -------------------------------------------------------------------------------- /alib2abstraction.xcframework/ios-x86_64-simulator/alib2abstraction.framework/Info.plist: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0ef6ee9eb01ebbc3e18c3ad61d0c02a4585d7764157adf4998103bfac07927c3 3 | size 798 4 | -------------------------------------------------------------------------------- /alib2common.xcframework/ios-arm64/alib2common.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:cc5d54473afd944919fc1f5d66d9bcc62384b68d797be7b26b74485e1db1dd2e 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2measure.xcframework/ios-arm64/alib2measure.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fbec840c0c96ccf9d8725c84627784fdf28264a0f1148bd9d79942cdba73ec82 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2measure.xcframework/ios-x86_64-simulator/alib2measure.framework/alib2measure: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5b8125b81a2a70036088b287049214d66b2513dcd219a5f859bea5795a973795 3 | size 702720 4 | -------------------------------------------------------------------------------- /alib2algo.xcframework/ios-x86_64-simulator/alib2algo.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0933131e14508453fdef97e3eef00f469b108cd84a44d3a3c01504c807c6aee0 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2data.xcframework/ios-x86_64-simulator/alib2data.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1331734a47a25406bfea2f3ea7952dc694b949054859c7ce361853b474419aeb 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2std.xcframework/ios-x86_64-simulator/alib2std.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:9a83b4c4371321005ab5389c4c7bfe9a491d0be289c6273a45974b87d291a5be 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2str.xcframework/ios-x86_64-simulator/alib2str.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d5d773e68500bb79b27d491d59535a55c38e19b856ceb5be666732a7cfd96047 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2xml.xcframework/ios-x86_64-simulator/alib2xml.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:227cb6b531888a9ba6d4ba69ebd15cef75cd742c8f837df736ac3f0e6d647080 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2abstraction.xcframework/ios-arm64/alib2abstraction.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1e91458d6e05fd6ae39e7ecd0da16d644c065307283f624ce707ea5b1bfc18eb 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2abstraction.xcframework/ios-x86_64-simulator/alib2abstraction.framework/alib2abstraction: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:915b569dddc2d7ea999d2721361e4eadd95ccb392c16358ef58368d9b91017e0 3 | size 3853504 4 | -------------------------------------------------------------------------------- /alib2common.xcframework/ios-x86_64-simulator/alib2common.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4c529b47786456a4a78c11f27b0d0cac97dceb3fbf336cb495d1a7294f1d1aff 3 | size 1798 4 | -------------------------------------------------------------------------------- /alib2measure.xcframework/ios-x86_64-simulator/alib2measure.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:551fb9d82712d0307a55ea76e60511d8af0796d0943bb496f046ca07e7390eb9 3 | size 1798 4 | -------------------------------------------------------------------------------- /AutomataEditor/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /alib2abstraction.xcframework/ios-x86_64-simulator/alib2abstraction.framework/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c60bb7ce6c8a84d2dc14977445c764ff70f628c7e1c83cf1e9b3fdfe5c1a0ca2 3 | size 1798 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "automata-editor-model"] 2 | path = automata-editor-model 3 | url = https://github.com/fortmarek/automata-editor-model 4 | [submodule "automata-library"] 5 | path = automata-library 6 | url = https://gitlab.fit.cvut.cz/fortmare/automata-library 7 | -------------------------------------------------------------------------------- /Tuist/Dependencies.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let dependencies = Dependencies( 4 | swiftPackageManager: [ 5 | .remote(url: "https://github.com/pointfreeco/swift-composable-architecture", requirement: .upToNextMajor(from: "0.19.0")), 6 | ], 7 | platforms: [.iOS] 8 | ) 9 | -------------------------------------------------------------------------------- /AutomataEditor/IDFactory/IDFactoryLive.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IDFactoryLive.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 30.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension IDFactory { 11 | static let live = Self( 12 | generateID: { UUID().uuidString } 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /AutomataEditor/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-App-1024x1024@1x.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/SwiftAutomataLibrary-BridgingHeader.h: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftAutomataLibrary-BridgingHeader.h 3 | // SwiftAutomataLibrary 4 | // 5 | // Created by Marek Fořt on 05.03.2021. 6 | // 7 | 8 | #ifndef SwiftAutomataLibrary_BridgingHeader_h 9 | #define SwiftAutomataLibrary_BridgingHeader_h 10 | 11 | #import "NFA.h" 12 | #import "Transition.h" 13 | 14 | #endif /* SwiftAutomataLibrary_BridgingHeader_h */ 15 | -------------------------------------------------------------------------------- /AutomataEditor/ShapeService/ShapeServiceLive.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShapeServiceLive.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 30.03.2021. 6 | // 7 | 8 | import Foundation 9 | import CoreGraphics 10 | 11 | extension ShapeService { 12 | static let live = Self( 13 | center: { $0.center() }, 14 | radius: { $0.radius(with: $1) }, 15 | circle: { .circle(center: $0, radius: $1) } 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /AutomataEditor/Assets.xcassets/Menu.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Menu.png", 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automata Editor 2 | 3 | This is a repository of Automata Editor which is an iPad app for editing finite automata. It uses CoreML to recognize your strokes and let you build and test your automaton quickly. 4 | It has been done as a part of [bachelor's thesis](https://github.com/fortmarek/bachelor-thesis). You can read more about it also in [this](https://marekfort.com/posts/automata-editor) blog post. 5 | 6 | Any feedback and suggestions are welcome! 7 | 8 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/AutomatonRunResult.m: -------------------------------------------------------------------------------- 1 | // 2 | // AutomatonRunResult.m 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 05.03.2021. 6 | // 7 | 8 | #import 9 | #include "AutomatonRunResult.h" 10 | 11 | @implementation AutomatonRunResult 12 | 13 | - (instancetype)initWithSucceeded: (bool)succeeded endStates:(NSArray *)endStates { 14 | self = [super init]; 15 | self->_succeeded = succeeded; 16 | self->_endStates = endStates; 17 | return self; 18 | } 19 | @end 20 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/NFA.h: -------------------------------------------------------------------------------- 1 | #ifndef Header_h 2 | #define Header_h 3 | 4 | #import 5 | #import "AutomatonRunResult.h" 6 | 7 | @interface NFA_objc: NSObject 8 | 9 | - (instancetype)init: (NSArray *) states 10 | inputAlphabet:(NSArray *) inputAlphabet 11 | initialState:(NSString *) initialState 12 | finalStates:(NSArray *) finalStates 13 | transitions: (NSArray *) transitions; 14 | - (bool)simulate: (NSArray *) input; 15 | 16 | @end 17 | 18 | #endif /* Header_h */ 19 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/Transition.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #include "Transition.h" 4 | 5 | @implementation Transition_objc 6 | 7 | -(instancetype)init: (NSString *) fromState toState:(NSString *) toState symbols:(NSArray *) symbols isEpsilonIncluded:(bool) isEpsilonIncluded { 8 | self = [super init]; 9 | self->_fromState = fromState; 10 | self->_toState = toState; 11 | self->_symbols = symbols; 12 | self->_isEpsilonIncluded = isEpsilonIncluded; 13 | return self; 14 | } 15 | 16 | @end 17 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/AutomatonRunResult.h: -------------------------------------------------------------------------------- 1 | // 2 | // AutomatonRunResult.h 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 05.03.2021. 6 | // 7 | 8 | #ifndef AutomatonRunResult_h 9 | #define AutomatonRunResult_h 10 | 11 | @interface AutomatonRunResult: NSObject 12 | 13 | - (instancetype)initWithSucceeded: (bool)succeeded endStates:(NSArray *)endStates; 14 | @property (nonatomic, assign, readonly) bool succeeded; 15 | @property (nonatomic, retain, readonly) NSArray* endStates; 16 | 17 | @end 18 | 19 | #endif /* AutomatonRunResult_h */ 20 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/Transition.swift: -------------------------------------------------------------------------------- 1 | public struct Transition { 2 | public let fromState: String 3 | public let toState: String 4 | public let symbols: [String] 5 | public let isEpsilonIncluded: Bool 6 | 7 | public init( 8 | fromState: String, 9 | toState: String, 10 | symbols: [String], 11 | isEpsilonIncluded: Bool 12 | ) { 13 | self.fromState = fromState 14 | self.toState = toState 15 | self.symbols = symbols 16 | self.isEpsilonIncluded = isEpsilonIncluded 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /AutomataEditor/FlexibleView/SizeReader.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func readSize(onChange: @escaping (CGSize) -> Void) -> some View { 5 | background( 6 | GeometryReader { geometryProxy in 7 | Color.clear 8 | .preference(key: SizePreferenceKey.self, value: geometryProxy.size) 9 | } 10 | ) 11 | .onPreferenceChange(SizePreferenceKey.self, perform: onChange) 12 | } 13 | } 14 | 15 | private struct SizePreferenceKey: PreferenceKey { 16 | static var defaultValue: CGSize = .zero 17 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} 18 | } 19 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/Transition.h: -------------------------------------------------------------------------------- 1 | #ifndef Transition_h 2 | #define Transition_h 3 | 4 | #import 5 | 6 | @interface Transition_objc: NSObject 7 | 8 | -(instancetype)init: (NSString *) fromState toState:(NSString *) toState symbols:(NSArray *) symbols isEpsilonIncluded:(bool) isEpsilonIncluded; 9 | @property (nonatomic, retain, readonly) NSString* fromState; 10 | @property (nonatomic, retain, readonly) NSString* toState; 11 | @property (nonatomic, retain, readonly) NSArray* symbols; 12 | @property (nonatomic, readonly) bool isEpsilonIncluded; 13 | 14 | @end 15 | 16 | #endif /* Transition_h */ 17 | -------------------------------------------------------------------------------- /AutomataEditor/AutomatonDocument.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AutomatonDocument: Equatable, Codable { 4 | let id: UUID 5 | let transitions: [AutomatonTransition.ID : AutomatonTransition] 6 | let automatonStates: [AutomatonState.ID : AutomatonState] 7 | 8 | init( 9 | id: UUID = UUID(), 10 | transitions: [AutomatonTransition.ID : AutomatonTransition] = [:], 11 | automatonStates: [AutomatonState.ID : AutomatonState] = [:] 12 | ) { 13 | self.id = id 14 | self.transitions = transitions 15 | self.automatonStates = automatonStates 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /AutomataEditor/View+If.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | /// Applies the given transform if the given condition evaluates to `true`. 5 | /// - Parameters: 6 | /// - condition: The condition to evaluate. 7 | /// - transform: The transform to apply to the source `View`. 8 | /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. 9 | @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { 10 | if condition { 11 | transform(self) 12 | } else { 13 | self 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /AutomataEditor/AutomataEditor.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.AutomataEditor 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudDocuments 14 | 15 | com.apple.developer.ubiquity-container-identifiers 16 | 17 | iCloud.AutomataEditor 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /AutomataEditor/IDFactory/IDFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IDFactory.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 30.03.2021. 6 | // 7 | 8 | import Foundation 9 | import ComposableArchitecture 10 | 11 | /// Factory for generating unique IDs 12 | struct IDFactory { 13 | let generateID: () -> String 14 | } 15 | 16 | private enum IDFactoryKey: DependencyKey { 17 | static let liveValue = IDFactory.live 18 | } 19 | 20 | extension DependencyValues { 21 | var idFactory: IDFactory { 22 | get { self[IDFactoryKey.self] } 23 | set { self[IDFactoryKey.self] = newValue } 24 | } 25 | } 26 | 27 | extension IDFactory { 28 | static func mock(_ generateID: @escaping () -> String) -> IDFactory { 29 | .init( 30 | generateID: generateID 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /AutomataEditorTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /AutomataEditor/AutomataEditorApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableArchitecture 3 | import Foundation 4 | 5 | @main 6 | struct AutomataEditorApp: App { 7 | init() { 8 | let coloredAppearance = UINavigationBarAppearance() 9 | 10 | coloredAppearance.configureWithOpaqueBackground() 11 | coloredAppearance.backgroundColor = .black 12 | 13 | UINavigationBar.appearance().standardAppearance = coloredAppearance 14 | UINavigationBar.appearance().compactAppearance = coloredAppearance 15 | UINavigationBar.appearance().scrollEdgeAppearance = coloredAppearance 16 | } 17 | 18 | var body: some Scene { 19 | WindowGroup { 20 | OverviewView( 21 | store: Store( 22 | initialState: OverviewFeature.State(), 23 | reducer: OverviewFeature() 24 | ) 25 | ) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /AutomataEditor/EditorButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorButton.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 27.03.2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EditorButton: View { 11 | let isSelected: Bool 12 | let image: Image 13 | let action: () -> Void 14 | var body: some View { 15 | Button( 16 | action: { 17 | action() 18 | } 19 | ) { 20 | image 21 | .resizable() 22 | .frame(width: 12, height: 12) 23 | .foregroundColor(isSelected ? Color.white : Color.blue) 24 | .padding(7) 25 | .background(isSelected ? Color.blue : Color.clear) 26 | .clipShape(Circle()) 27 | .overlay( 28 | Circle() 29 | .stroke(Color.blue, lineWidth: 2) 30 | ) 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /AutomataEditor/AutomatonState.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct AutomatonState: Equatable, Identifiable, Codable { 4 | let id: String 5 | var name: String = "" 6 | var isFinalState: Bool = false 7 | var center: CGPoint 8 | let radius: CGFloat 9 | var dragPoint: CGPoint 10 | 11 | init( 12 | id: String, 13 | center: CGPoint, 14 | radius: CGFloat 15 | ) { 16 | self.id = id 17 | self.center = center 18 | self.radius = radius 19 | self.dragPoint = CGPoint( 20 | x: center.x, 21 | y: center.y - radius 22 | ) 23 | } 24 | 25 | var currentDragPoint: CGPoint { 26 | get { 27 | CGPoint( 28 | x: center.x, 29 | y: center.y - radius 30 | ) 31 | } 32 | set { 33 | center.x = newValue.x 34 | center.y = newValue.y + radius 35 | } 36 | } 37 | 38 | var scribblePosition: CGPoint { 39 | center 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /AutomataEditor/Stroke.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import PencilKit 3 | 4 | struct Stroke: Equatable, Hashable { 5 | let controlPoints: [CGPoint] 6 | 7 | init( 8 | controlPoints: [CGPoint] 9 | ) { 10 | self.controlPoints = controlPoints 11 | } 12 | 13 | init(_ stroke: PKStroke) { 14 | controlPoints = stroke.path 15 | .interpolatedPoints(by: .distance(50)) 16 | .map(\.location) 17 | } 18 | 19 | func pkStroke() -> PKStroke { 20 | PKStroke( 21 | ink: PKInk(.pen), 22 | path: PKStrokePath( 23 | controlPoints: controlPoints.map(strokePoint), 24 | creationDate: Date() 25 | ) 26 | ) 27 | } 28 | 29 | private func strokePoint( 30 | _ location: CGPoint 31 | ) -> PKStrokePoint { 32 | PKStrokePoint( 33 | location: location, 34 | timeOffset: 0, 35 | size: CGSize(width: 4, height: 4), 36 | opacity: 1, 37 | force: 1, 38 | azimuth: 0, 39 | altitude: 0 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/NFA.swift: -------------------------------------------------------------------------------- 1 | public struct NFA { 2 | private let automaton: NFA_objc 3 | 4 | public init( 5 | states: [String], 6 | inputAlphabet: [String], 7 | initialState: String, 8 | finalStates: [String], 9 | transitions: [Transition] 10 | ) { 11 | let transitions: [Transition_objc] = transitions 12 | .map { 13 | Transition_objc( 14 | $0.fromState, 15 | toState: $0.toState, 16 | symbols: $0.symbols, 17 | isEpsilonIncluded: $0.isEpsilonIncluded 18 | ) 19 | } 20 | automaton = NFA_objc( 21 | states, 22 | inputAlphabet: inputAlphabet, 23 | initialState: initialState, 24 | finalStates: finalStates, 25 | transitions: transitions 26 | ) 27 | } 28 | 29 | public func simulate(input: [String]) -> Bool { 30 | automaton.simulate( 31 | input 32 | ) 33 | } 34 | } 35 | 36 | public enum AutomatonRunResult { 37 | case succeeded([String]) 38 | case failed([String]) 39 | } 40 | -------------------------------------------------------------------------------- /AutomataEditor/Overview/Image+OverviewItemStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableArchitecture 3 | 4 | extension Image { 5 | func overviewItemStyle(isSelected: Bool) -> some View { 6 | self 7 | .resizable() 8 | .frame(width: 80, height: 80) 9 | .padding(.vertical, 50) 10 | .foregroundColor(.blue) 11 | .frame(maxWidth: .infinity) 12 | .background(.white) 13 | .if(isSelected) { 14 | $0.overlay( 15 | alignment: .bottomTrailing 16 | ) { 17 | Image(systemName: "checkmark.circle.fill") 18 | .resizable() 19 | .frame(width: 20, height: 20) 20 | .padding([.trailing, .bottom], 10) 21 | } 22 | } 23 | .cornerRadius(20) 24 | .padding(2) 25 | .if(isSelected) { 26 | $0 27 | .overlay( 28 | RoundedRectangle(cornerRadius: 21) 29 | .stroke(Color.blue, lineWidth: 2) 30 | ) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/AddTransitionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AddTransitionView: View { 4 | var point: CGPoint 5 | var isSelected: Bool 6 | let selected: () -> Void 7 | 8 | var body: some View { 9 | Button(action: { selected() }) { 10 | ZStack { 11 | Circle() 12 | .strokeBorder(.blue, lineWidth: 2) 13 | 14 | if isSelected { 15 | Circle() 16 | .fill(.blue) 17 | 18 | Image(systemName: "checkmark") 19 | .foregroundColor(.white) 20 | } 21 | } 22 | } 23 | .frame(width: 30) 24 | .position(point) 25 | } 26 | } 27 | 28 | struct AddTransitionView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | AddTransitionView( 31 | point: CGPoint(x: 400, y: 500), 32 | isSelected: false, 33 | selected: { } 34 | ) 35 | AddTransitionView( 36 | point: CGPoint(x: 400, y: 500), 37 | isSelected: true, 38 | selected: { } 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/xcode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=xcode 4 | 5 | ### Xcode ### 6 | # Xcode 7 | # 8 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 9 | 10 | ## User settings 11 | xcuserdata/ 12 | 13 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 14 | *.xcscmblueprint 15 | *.xccheckout 16 | 17 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 18 | build/ 19 | DerivedData/ 20 | *.moved-aside 21 | *.pbxuser 22 | !default.pbxuser 23 | *.mode1v3 24 | !default.mode1v3 25 | *.mode2v3 26 | !default.mode2v3 27 | *.perspectivev3 28 | !default.perspectivev3 29 | 30 | ## Gcc Patch 31 | /*.gcno 32 | 33 | ### Xcode Patch ### 34 | *.xcodeproj/* 35 | !*.xcodeproj/project.pbxproj 36 | !*.xcodeproj/xcshareddata/ 37 | !*.xcworkspace/contents.xcworkspacedata 38 | **/xcshareddata/WorkspaceSettings.xcsettings 39 | 40 | # End of https://www.toptal.com/developers/gitignore/api/xcode 41 | 42 | # Tuist 43 | **/Derived 44 | **/*.xcodeproj 45 | **/*.xcworkspace 46 | 47 | ### Tuist managed dependencies ### 48 | Tuist/Dependencies -------------------------------------------------------------------------------- /AutomataEditor/AutomataLibraryService/AutomataLibraryService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutomataLibraryService.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 05.03.2021. 6 | // 7 | 8 | import Foundation 9 | import ComposableArchitecture 10 | import Combine 11 | 12 | enum AutomataLibraryError: Error, Equatable { 13 | case failed 14 | } 15 | 16 | private enum AutomataLibraryServiceKey: DependencyKey { 17 | static let liveValue = AutomataLibraryService.live 18 | } 19 | 20 | extension DependencyValues { 21 | var automataLibraryService: AutomataLibraryService { 22 | get { self[AutomataLibraryServiceKey.self] } 23 | set { self[AutomataLibraryServiceKey.self] = newValue } 24 | } 25 | } 26 | 27 | /// Service to interact with ALT frameworks 28 | struct AutomataLibraryService { 29 | /// Simulates input for a given FA. 30 | /// Throws `AutomataLibraryError` if the input was rejected. 31 | let simulateInput: ( 32 | _ input: [String], 33 | _ states: [AutomatonState], 34 | _ initialState: AutomatonState, 35 | _ finalStates: [AutomatonState], 36 | _ alphabet: [String], 37 | _ transitions: [AutomatonTransition] 38 | ) throws -> Void 39 | } 40 | 41 | extension AutomataLibraryService { 42 | static func successful() -> Self { 43 | Self(simulateInput: { _, _, _, _, _, _ in }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /AutomataEditor/DocumentPicker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import UIKit 4 | 5 | final class DocumentPickerCoordinator: NSObject, UIDocumentPickerDelegate { 6 | private var parent: DocumentPicker 7 | 8 | init(parent: DocumentPicker){ 9 | self.parent = parent 10 | } 11 | 12 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { 13 | guard let url = urls.first else { return } 14 | parent.selectedDocument(url) 15 | } 16 | } 17 | 18 | struct DocumentPicker: UIViewControllerRepresentable { 19 | let selectedDocument: (URL) -> Void 20 | 21 | func makeCoordinator() -> DocumentPickerCoordinator { 22 | return DocumentPickerCoordinator(parent: self) 23 | } 24 | 25 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIDocumentPickerViewController { 26 | let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.automatonDocument]) 27 | picker.allowsMultipleSelection = false 28 | picker.delegate = context.coordinator 29 | return picker 30 | } 31 | 32 | func updateUIViewController( 33 | _ uiViewController: DocumentPicker.UIViewControllerType, 34 | context: UIViewControllerRepresentableContext 35 | ) { 36 | // noop 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /AutomataEditor/ShapeService/ShapeService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShapeService.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 30.03.2021. 6 | // 7 | 8 | import Foundation 9 | import CoreGraphics 10 | import ComposableArchitecture 11 | 12 | /// Service for working with shapes and their points. 13 | struct ShapeService { 14 | /// Returns a center from an array of points. 15 | let center: ([CGPoint]) -> CGPoint 16 | /// Returns a radius of a circle from given points and a center. 17 | let radius: (_ points: [CGPoint], _ center: CGPoint) -> CGFloat 18 | /// Returns a circle for a given center and a radius 19 | let circle: (_ center: CGPoint, _ radius: CGFloat) -> [CGPoint] 20 | } 21 | 22 | private enum ShapeServiceKey: DependencyKey { 23 | static let liveValue = ShapeService.live 24 | } 25 | 26 | extension DependencyValues { 27 | var shapeService: ShapeService { 28 | get { self[ShapeServiceKey.self] } 29 | set { self[ShapeServiceKey.self] = newValue } 30 | } 31 | } 32 | 33 | extension ShapeService { 34 | static func mock( 35 | center: @escaping ([CGPoint]) -> CGPoint, 36 | radius: @escaping (_ points: [CGPoint], _ center: CGPoint) -> CGFloat 37 | ) -> Self { 38 | .init( 39 | center: center, 40 | radius: radius, 41 | circle: { center, _ in 42 | [center] 43 | } 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /AutomataEditorTests/OverviewTests/OverviewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import ComposableArchitecture 3 | @testable import AutomataEditor 4 | 5 | @MainActor 6 | final class OverviewTests: XCTestCase { 7 | let automatonURL = URL(string: "file://some-file")! 8 | 9 | func testAutomatonIsSavedWhenEditorStateIsUpdated() async throws { 10 | let store = TestStore( 11 | initialState: OverviewFeature.State(), 12 | reducer: OverviewFeature() 13 | ) 14 | let stubID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000000")) 15 | store.dependencies.automatonDocumentService = .mock 16 | store.dependencies.idFactory = .mock { stubID.uuidString } 17 | 18 | let state = EditorFeature.State( 19 | automatonURL: automatonURL, 20 | id: stubID, 21 | automatonStatesDict: ["A": AutomatonState(id: "A", center: .zero, radius: .zero)] 22 | ) 23 | await store.send(.loadedAutomaton(automatonURL, AutomatonDocument(id: stubID))) { [self] in 24 | $0.editor = EditorFeature.State(automatonURL: self.automatonURL, id: stubID) 25 | $0.isEditorPresented = true 26 | } 27 | await store.send( 28 | .editor( 29 | .stateUpdated( 30 | state 31 | ) 32 | ) 33 | ) 34 | await store.receive(.automatonSaved) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/ToastView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ToastView: View { 4 | let image: String? 5 | let imageColor: Color 6 | let title: String 7 | let subtitle: String? 8 | 9 | var body: some View { 10 | HStack(spacing: 16) { 11 | if let image = image { 12 | Image(systemName: image) 13 | .resizable() 14 | .scaledToFit() 15 | .frame(width: 28, height: 28) 16 | .foregroundColor(imageColor) 17 | } 18 | 19 | VStack(alignment: .center) { 20 | Text(title) 21 | .lineLimit(1) 22 | .font(.headline) 23 | .foregroundColor(.white) 24 | 25 | if let subtitle = subtitle { 26 | Text(subtitle) 27 | .lineLimit(1) 28 | .font(.subheadline) 29 | .foregroundColor(.secondary) 30 | } 31 | } 32 | .padding(image == nil ? .horizontal : .trailing) 33 | } 34 | .padding(.horizontal) 35 | .frame(height: 56) 36 | .background(Color(UIColor.secondarySystemBackground)) 37 | .cornerRadius(28) 38 | .shadow(color: Color(UIColor.black.withAlphaComponent(0.08)), radius: 8, x: 0, y: 4) 39 | .padding(.top, 20) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/AutomatonInput.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableArchitecture 3 | 4 | struct AutomatonInput: View { 5 | let viewStore: ViewStoreOf 6 | 7 | var body: some View { 8 | VStack { 9 | HStack { 10 | Spacer() 11 | HStack { 12 | TextField( 13 | "Automaton input", 14 | text: viewStore.binding( 15 | get: \.input, 16 | send: { .inputChanged($0) } 17 | ) 18 | ) 19 | .textInputAutocapitalization(TextInputAutocapitalization.never) 20 | .autocorrectionDisabled(true) 21 | .foregroundColor(.white) 22 | Button( 23 | action: { 24 | viewStore.send(.removeLastInputSymbol) 25 | } 26 | ) { 27 | Image(systemName: "delete.left") 28 | } 29 | } 30 | .frame(width: 200) 31 | .padding(15) 32 | .background(Color(UIColor.secondarySystemBackground)) 33 | .cornerRadius(28) 34 | .shadow(color: Color(UIColor.black.withAlphaComponent(0.08)), radius: 8, x: 0, y: 4) 35 | .padding([.top, .trailing], 10) 36 | } 37 | Spacer() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /AutomataEditor/CGPoint+Extra.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | 4 | extension CGPoint { 5 | func distance(from other: CGPoint) -> CGFloat { 6 | pow(other.x - x, 2) + pow(other.y - y, 2) 7 | } 8 | } 9 | 10 | extension Array where Element == CGPoint { 11 | func closestPoint(from point: CGPoint) -> CGPoint { 12 | reduce((CGPoint.zero, CGFloat.infinity)) { acc, current in 13 | let currentDistance = current.distance(from: point) 14 | return currentDistance < acc.1 ? (current, currentDistance) : acc 15 | } 16 | .0 17 | } 18 | 19 | func furthestPoint(from point: CGPoint) -> CGPoint { 20 | reduce((CGPoint.zero, -CGFloat.infinity)) { acc, current in 21 | let currentDistance = current.distance(from: point) 22 | return currentDistance > acc.1 ? (current, currentDistance) : acc 23 | } 24 | .0 25 | } 26 | 27 | func center() -> CGPoint { 28 | let (sumX, sumY, count): (CGFloat, CGFloat, CGFloat) = reduce((CGFloat(0), CGFloat(0), CGFloat(0))) { acc, current in 29 | (acc.0 + current.x, acc.1 + current.y, acc.2 + 1) 30 | } 31 | return CGPoint(x: sumX / count, y: sumY / count) 32 | } 33 | 34 | func radius(with center: CGPoint) -> CGFloat { 35 | let sumDistance = reduce(0) { acc, current in 36 | acc + abs(center.x - current.x) + abs(center.y - current.y) 37 | } 38 | return sumDistance / CGFloat(count) 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /AutomataEditor/Overview/HelpView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AVKit 3 | 4 | struct HelpView: View { 5 | var body: some View { 6 | ScrollView { 7 | VStack { 8 | Text("Automata editor") 9 | .lineLimit(nil) 10 | .font(.headline) 11 | .padding() 12 | 13 | Text("Automata editor is an app that allows you to construct arbitrary nondeterministic finite automata (NFAs) and simulate whether an input is rejected or not. You can construct automata using the top menu as in the image below:") 14 | .lineLimit(nil) 15 | .font(.body) 16 | .padding() 17 | 18 | Image("Menu") 19 | .resizable() 20 | .scaledToFit() 21 | .padding() 22 | 23 | Text("You can also use your Apple Pencil and draw on the canvas – the app will recognize the individual automata elements (state, transition, cycle) as in the following video:") 24 | .lineLimit(nil) 25 | .font(.body) 26 | .padding() 27 | 28 | VideoPlayer( 29 | player: AVPlayer( 30 | url: URL(fileURLWithPath: Bundle.main.path(forResource: "draw", ofType: "mp4")!) 31 | ) 32 | ) 33 | .scaledToFit() 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /AutomataEditor/AutomatonDocumentService/AutomatonDocumentService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ComposableArchitecture 3 | import Combine 4 | 5 | private enum AutomatonDocumentServiceKey: DependencyKey { 6 | static let liveValue = AutomatonDocumentService.live 7 | } 8 | 9 | extension DependencyValues { 10 | var automatonDocumentService: AutomatonDocumentService { 11 | get { self[AutomatonDocumentServiceKey.self] } 12 | set { self[AutomatonDocumentServiceKey.self] = newValue } 13 | } 14 | } 15 | 16 | /// Service to interact with `AutomatonDocument`s 17 | struct AutomatonDocumentService { 18 | /// Creates a new `AutomatonDocument` in the ubiquituous folder 19 | /// Throws `AutomatonDocumentServiceError` if the file could not be created 20 | let createNewAutomaton: (String) async throws -> URL 21 | /// Reads the automaton from a given URL 22 | let readAutomaton: (URL) async throws -> AutomatonDocument 23 | /// Loads automata from the ubiquity container 24 | let loadAutomata: () async throws -> [URL] 25 | /// Saves automaton to a given URL 26 | let saveAutomaton: (URL, AutomatonDocument) throws -> Void 27 | /// Deletes automata at given URLs 28 | let deleteAutomata: ([URL]) throws -> Void 29 | } 30 | 31 | extension AutomatonDocumentService { 32 | static let mock = Self( 33 | createNewAutomaton: { _ in fatalError() }, 34 | readAutomaton: { _ in fatalError() }, 35 | loadAutomata: { [] }, 36 | saveAutomaton: { _, _ in }, 37 | deleteAutomata: { _ in } 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /AutomataEditor/AutomataClassifierService/AutomataClassifierServiceLive.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreML 3 | import UIKit 4 | import PencilKit 5 | import ComposableArchitecture 6 | import Combine 7 | 8 | enum AutomatonShapeType: String { 9 | case circle 10 | case arrow 11 | case cycle 12 | } 13 | 14 | extension AutomataClassifierService { 15 | static let live = Self( 16 | recognizeStroke: { stroke in 17 | guard 18 | let image = PKDrawing(strokes: [stroke.pkStroke()]) 19 | .image( 20 | from: stroke.pkStroke().renderBounds, 21 | scale: 1.0 22 | ) 23 | .modelImage(), 24 | let cgImage = image.cgImage 25 | else { 26 | throw AutomataClassifierError.shapeNotRecognized 27 | } 28 | 29 | let input = try AutomataClassifierInput(drawingWith: cgImage) 30 | let classifier = try AutomataClassifier(configuration: MLModelConfiguration()) 31 | let prediction = try classifier.prediction(input: input) 32 | 33 | guard 34 | let automataShapeType = AutomatonShapeType(rawValue: prediction.label) 35 | else { throw AutomataClassifierError.shapeNotRecognized } 36 | 37 | switch automataShapeType { 38 | case .arrow: 39 | return .transition(stroke) 40 | case .circle: 41 | return .state(stroke) 42 | case .cycle: 43 | return .transitionCycle(stroke) 44 | } 45 | } 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /AutomataEditor/AutomataLibraryService/AutomataLibraryServiceLive.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutomataLibraryServiceLive.swift 3 | // AutomataEditor 4 | // 5 | // Created by Marek Fořt on 05.03.2021. 6 | // 7 | 8 | import Foundation 9 | import ComposableArchitecture 10 | import SwiftAutomataLibrary 11 | import Combine 12 | 13 | extension AutomataLibraryService { 14 | static let live = Self { input, states, initialState, finalStates, alphabet, transitions in 15 | let accepted = NFA( 16 | states: states.map(\.name), 17 | inputAlphabet: alphabet, 18 | initialState: initialState.name, 19 | finalStates: finalStates.map(\.name), 20 | transitions: transitions 21 | .compactMap { transition -> Transition? in 22 | guard 23 | let startState = states.first(where: { $0.id == transition.startState }), 24 | let endState = states.first(where: { $0.id == transition.endState }) 25 | else { return nil } 26 | return Transition( 27 | fromState: startState.name, 28 | toState: endState.name, 29 | symbols: transition.symbols 30 | + (transition.currentSymbol.isEmpty ? [] : [transition.currentSymbol]), 31 | isEpsilonIncluded: transition.includesEpsilon 32 | ) 33 | } 34 | ) 35 | .simulate(input: input) 36 | 37 | if accepted { 38 | // noop 39 | } else { 40 | throw AutomataLibraryError.failed 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /AutomataEditor/FlexibleView/_FlexibleView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// This view is responsible to lay down the given elements and wrap them into 4 | /// multiple rows if needed. 5 | struct _FlexibleView: View where Data.Element: Hashable { 6 | let availableWidth: CGFloat 7 | let data: Data 8 | let spacing: CGFloat 9 | let alignment: HorizontalAlignment 10 | let content: (Data.Element) -> Content 11 | @State var elementsSize: [Data.Element: CGSize] = [:] 12 | 13 | var body : some View { 14 | VStack(alignment: alignment, spacing: spacing) { 15 | ForEach(computeRows(), id: \.self) { rowElements in 16 | HStack(spacing: spacing) { 17 | ForEach(rowElements, id: \.self) { element in 18 | content(element) 19 | .fixedSize() 20 | .readSize { size in 21 | elementsSize[element] = size 22 | } 23 | } 24 | Spacer() 25 | } 26 | } 27 | } 28 | } 29 | 30 | func computeRows() -> [[Data.Element]] { 31 | var rows: [[Data.Element]] = [[]] 32 | var currentRow = 0 33 | var remainingWidth = availableWidth 34 | 35 | for element in data { 36 | let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)] 37 | 38 | if remainingWidth - (elementSize.width + spacing) >= 0 { 39 | rows[currentRow].append(element) 40 | } else { 41 | currentRow = currentRow + 1 42 | rows.append([element]) 43 | remainingWidth = availableWidth 44 | } 45 | 46 | remainingWidth = remainingWidth - (elementSize.width + spacing) 47 | } 48 | 49 | return rows 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /AutomataEditor/AutomataClassifierService/AutomataClassifierService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import ComposableArchitecture 4 | 5 | /// Automaton shapes with their strokes as associated values. 6 | enum AutomatonShape: Equatable { 7 | case transition(Stroke) 8 | case transitionCycle(Stroke) 9 | case state(Stroke) 10 | } 11 | 12 | enum AutomataClassifierError: Error, Equatable { 13 | case shapeNotRecognized 14 | } 15 | 16 | private enum AutomataClassifierServiceKey: DependencyKey { 17 | static let liveValue = AutomataClassifierService.live 18 | } 19 | 20 | extension DependencyValues { 21 | var automataClassifierService: AutomataClassifierService { 22 | get { self[AutomataClassifierServiceKey.self] } 23 | set { self[AutomataClassifierServiceKey.self] = newValue } 24 | } 25 | } 26 | 27 | /// Service to classify strokes as `AutomatonShape`. 28 | struct AutomataClassifierService { 29 | /// Recognizes stroke and returns it as a case of `AutomatonShape` 30 | let recognizeStroke: (Stroke) async throws -> AutomatonShape 31 | } 32 | 33 | extension AutomataClassifierService { 34 | static let successfulTransition = Self( 35 | recognizeStroke: { stroke in 36 | .transition(stroke) 37 | } 38 | ) 39 | static let successfulState = Self( 40 | recognizeStroke: { stroke in 41 | .state(stroke) 42 | } 43 | ) 44 | 45 | static func successfulShape(_ shape: @escaping () -> AutomatonShapeType) -> Self { 46 | Self( 47 | recognizeStroke: { stroke in 48 | let automatonShape: AutomatonShape 49 | switch shape() { 50 | case .arrow: 51 | automatonShape = .transition(stroke) 52 | case .circle: 53 | automatonShape = .state(stroke) 54 | case .cycle: 55 | automatonShape = .transitionCycle(stroke) 56 | } 57 | 58 | return automatonShape 59 | } 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /AutomataEditor/FlexibleView/FlexibleView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Facade of our view, its main responsibility is to get the available width 4 | /// and pass it down to the real implementation, `_FlexibleView`. 5 | /// This view (and its subviews) has been highly inspied but modified from: https://github.com/zntfdr/FiveStarsCodeSamples/tree/48e493a2b4acd7196c176689a8f3038936f0ed41/Flexible-SwiftUI/Flexible 6 | struct FlexibleView: View where Data.Element: Hashable { 7 | let data: Data 8 | let spacing: CGFloat 9 | let alignment: HorizontalAlignment 10 | let content: (Data.Element) -> Content 11 | @State private var availableWidth: CGFloat = 0 12 | 13 | var body: some View { 14 | ZStack(alignment: Alignment(horizontal: alignment, vertical: .center)) { 15 | Color.clear 16 | .frame(height: 1) 17 | .readSize { size in 18 | availableWidth = size.width 19 | } 20 | 21 | _FlexibleView( 22 | availableWidth: availableWidth, 23 | data: data, 24 | spacing: spacing, 25 | alignment: alignment, 26 | content: content 27 | ) 28 | } 29 | } 30 | } 31 | 32 | struct FlexibleView_Previews: PreviewProvider { 33 | static var previews: some View { 34 | FlexibleView( 35 | data: ["A", "B"], 36 | spacing: 3, 37 | alignment: .leading, 38 | content: { text in 39 | HStack { 40 | Text(text) 41 | .foregroundColor(Color.black) 42 | Button( 43 | action: { } 44 | ) { 45 | Image(systemName: "xmark") 46 | .foregroundColor(Color.black) 47 | } 48 | } 49 | .padding(.all, 5) 50 | .background(Color.white) 51 | .cornerRadius(10) 52 | } 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /AutomataEditor/AutomatonDocumentService/AutomatonDocumentServiceLive.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import ComposableArchitecture 4 | 5 | enum AutomatonDocumentServiceError: Error { 6 | case urlNotFound 7 | } 8 | 9 | extension AutomatonDocumentService { 10 | private static var url: URL? { 11 | FileManager.default 12 | .url(forUbiquityContainerIdentifier: nil)? 13 | .appendingPathComponent("Documents") ?? 14 | FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 15 | } 16 | 17 | static let live: Self = Self( 18 | createNewAutomaton: { automatonName in 19 | guard 20 | let url = url 21 | else { 22 | throw AutomatonDocumentServiceError.urlNotFound 23 | } 24 | let fileURL = url.appendingPathComponent("\(automatonName).automaton") 25 | let automaton = AutomatonDocument() 26 | let jsonEncoder = JSONEncoder() 27 | let data = try jsonEncoder.encode(automaton) 28 | try data.write(to: fileURL) 29 | 30 | return fileURL 31 | }, 32 | readAutomaton: { url in 33 | let data = try Data(contentsOf: url) 34 | let jsonDecoder = JSONDecoder() 35 | let automatonDocument = try jsonDecoder.decode(AutomatonDocument.self, from: data) 36 | return automatonDocument 37 | }, 38 | loadAutomata: { 39 | guard 40 | let url = url 41 | else { 42 | throw AutomatonDocumentServiceError.urlNotFound 43 | } 44 | return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) 45 | .filter { !$0.hasDirectoryPath } 46 | }, 47 | saveAutomaton: { url, automatonDocument in 48 | let jsonEncoder = JSONEncoder() 49 | let data = try jsonEncoder.encode(automatonDocument) 50 | try data.write(to: url) 51 | }, 52 | deleteAutomata: { urls in 53 | try urls.forEach(FileManager.default.removeItem) 54 | } 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /AutomataEditor/UIImage+Extra.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import func AVFoundation.AVMakeRect 3 | 4 | extension UIImage { 5 | func modelImage( 6 | with size: CGSize = CGSize(width: 28, height: 28) 7 | ) -> UIImage? { 8 | resize( 9 | to: size 10 | )? 11 | .grayscale() 12 | } 13 | 14 | func resize(to newSize: CGSize) -> UIImage? { 15 | UIGraphicsImageRenderer(size: newSize) 16 | .image { _ in 17 | draw( 18 | in: AVMakeRect( 19 | aspectRatio: size, 20 | insideRect: CGRect(origin: .zero, size: newSize) 21 | ) 22 | ) 23 | } 24 | } 25 | 26 | // Taken and modified from: https://prograils.com/grayscale-conversion-swift 27 | func grayscale() -> UIImage? { 28 | // Create image rectangle with current image width/height 29 | let imageRect: CGRect = CGRect(x: 0, y: 0, width: size.width, height: size.height) 30 | 31 | // Grayscale color space 32 | let colorSpace: CGColorSpace = CGColorSpaceCreateDeviceGray() 33 | 34 | // Create bitmap content with current image size and grayscale colorspace 35 | let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) 36 | guard 37 | let context = CGContext( 38 | data: nil, 39 | width: Int(size.width), 40 | height: Int(size.height), 41 | bitsPerComponent: 8, 42 | bytesPerRow: 0, 43 | space: colorSpace, 44 | bitmapInfo: bitmapInfo.rawValue 45 | ), 46 | let cgImage = cgImage 47 | else { return nil } 48 | // Draw image into current context, with specified rectangle using previously defined context (with grayscale colorspace) 49 | context.draw(cgImage, in: imageRect) 50 | 51 | // Create bitmap image info from pixel data in current context 52 | guard let imageRef: CGImage = context.makeImage() else { return nil } 53 | 54 | // Create a new UIImage object 55 | let newImage: UIImage = UIImage(cgImage: imageRef) 56 | 57 | // Return the new grayscale image 58 | return newImage 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "combine-schedulers", 6 | "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", 7 | "state": { 8 | "branch": null, 9 | "revision": "c37e5ae8012fb654af776cc556ff8ae64398c841", 10 | "version": "0.5.0" 11 | } 12 | }, 13 | { 14 | "package": "swift-case-paths", 15 | "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", 16 | "state": { 17 | "branch": null, 18 | "revision": "5904cc74af2890ec34c4bc00fe8d8956c52b1e88", 19 | "version": "0.5.0" 20 | } 21 | }, 22 | { 23 | "package": "swift-collections", 24 | "repositoryURL": "https://github.com/apple/swift-collections", 25 | "state": { 26 | "branch": null, 27 | "revision": "3426dba9ee5c9f8e4981b0fc9d39a818d36eec28", 28 | "version": "0.0.4" 29 | } 30 | }, 31 | { 32 | "package": "swift-composable-architecture", 33 | "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture", 34 | "state": { 35 | "branch": null, 36 | "revision": "a2319ff82b07d12af1d5ce233e22c75e5615fd96", 37 | "version": "0.22.0" 38 | } 39 | }, 40 | { 41 | "package": "swift-identified-collections", 42 | "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections", 43 | "state": { 44 | "branch": null, 45 | "revision": "edececbdb56b07e9402f2f3f5b907d220a28b4ea", 46 | "version": "0.1.1" 47 | } 48 | }, 49 | { 50 | "package": "SwiftSplines", 51 | "repositoryURL": "https://github.com/Bersaelor/SwiftSplines", 52 | "state": { 53 | "branch": null, 54 | "revision": "2288e9fbf3245f1b89b69c23bebd30461aa0f40c", 55 | "version": "0.3.0" 56 | } 57 | }, 58 | { 59 | "package": "xctest-dynamic-overlay", 60 | "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", 61 | "state": { 62 | "branch": null, 63 | "revision": "152390e9e78ebbf0d767ee52971d41e7f44f39bc", 64 | "version": "0.1.1" 65 | } 66 | } 67 | ] 68 | }, 69 | "version": 1 70 | } 71 | -------------------------------------------------------------------------------- /AutomataEditor/Overview/OverviewGrid.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableArchitecture 3 | 4 | struct OverviewGrid: View { 5 | let store: StoreOf 6 | 7 | var body: some View { 8 | WithViewStore(store) { viewStore in 9 | LazyVGrid( 10 | columns: [ 11 | GridItem(.flexible(), spacing: 50), 12 | GridItem(.flexible(), spacing: 50), 13 | GridItem(.flexible(), spacing: 50), 14 | GridItem(.flexible(), spacing: 50), 15 | ] 16 | ) { 17 | Button( 18 | action: { viewStore.send(.isAlertForNewAutomatonNamePresentedChanged(true)) } 19 | ) { 20 | VStack { 21 | Image(systemName: "plus.circle") 22 | .overviewItemStyle(isSelected: false) 23 | Text("Create new automaton") 24 | .foregroundColor(.white) 25 | } 26 | } 27 | ForEach(viewStore.automatonFiles, id: \.url) { automaton in 28 | Button( 29 | action: { viewStore.send(.selectedAutomaton(automaton.url)) } 30 | ) { 31 | VStack { 32 | Image(systemName: "pencil.and.outline") 33 | .overviewItemStyle( 34 | isSelected: viewStore.selectedAutomatonFileIDs.contains(automaton.id) 35 | ) 36 | Text(automaton.name) 37 | .foregroundColor(.white) 38 | } 39 | } 40 | } 41 | } 42 | .padding() 43 | .navigationDestination( 44 | isPresented: viewStore.binding( 45 | get: \.isEditorPresented, 46 | send: OverviewFeature.Action.isEditorPresentedChanged 47 | ) 48 | ) { 49 | IfLetStore( 50 | self.store.scope( 51 | state: \.editor, 52 | action: OverviewFeature.Action.editor 53 | ) 54 | ) { 55 | EditorView(store: $0) 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/EditorToolbar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableArchitecture 3 | 4 | struct EditorToolbar: ToolbarContent { 5 | let viewStore: ViewStoreOf 6 | 7 | var body: some ToolbarContent { 8 | ToolbarItemGroup(placement: .principal) { 9 | HStack { 10 | Button(action: { viewStore.send(.simulateInput) }) { 11 | Image(systemName: "play.fill") 12 | } 13 | Button(action: { viewStore.send(.selectedPen) }) { 14 | Image(systemName: viewStore.state.isPenSelected ? "pencil.circle.fill" : "pencil.circle") 15 | } 16 | Button(action: { viewStore.send(.selectedEraser) }) { 17 | Image(systemName: viewStore.state.isEraserSelected ? "eraser.fill" : "eraser") 18 | } 19 | Menu { 20 | Button(action: { viewStore.send(.addNewState) }) { 21 | Label("State", systemImage: "circle") 22 | } 23 | Button(action: { viewStore.send(.startAddingTransition) }) { 24 | Label("Transition", systemImage: "arrow.right") 25 | } 26 | Button(action: { viewStore.send(.startAddingCycle) }) { 27 | Label("Cycle", systemImage: "arrow.counterclockwise") 28 | } 29 | Button(action: { viewStore.send(.startAddingFinalState) }) { 30 | Label("Final state", systemImage: "circle.circle") 31 | } 32 | Button(action: { viewStore.send(.startAddingInitialState) }) { 33 | Label("Initial state", systemImage: "arrow.right.to.line") 34 | } 35 | } label: { 36 | Label("Add new element", systemImage: "plus.circle") 37 | } 38 | } 39 | } 40 | ToolbarItemGroup(placement: .primaryAction) { 41 | switch viewStore.mode { 42 | case .editing, .erasing: 43 | Button(action: { viewStore.send(.clearButtonPressed) }) { 44 | Image(systemName: "trash") 45 | } 46 | case .addingTransition: 47 | Button("Cancel", action: { viewStore.send(.stopAddingTransition) }) 48 | case .addingCycle: 49 | Button("Cancel", action: { viewStore.send(.stopAddingCycle) }) 50 | case .addingFinalState: 51 | Button("Cancel", action: { viewStore.send(.stopAddingFinalState) }) 52 | case .addingInitialState: 53 | Button("Cancel", action: { viewStore.send(.stopAddingInitialState) }) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /SwiftAutomataLibrary/NFA.mm: -------------------------------------------------------------------------------- 1 | #import 2 | #import "Transition.h" 3 | #import "NFA.h" 4 | #import "AutomatonRunResult.h" 5 | 6 | #include "automaton/FSM/EpsilonNFA.h" 7 | #include "automaton/run/Accept.h" 8 | 9 | @implementation NFA_objc { 10 | automaton::EpsilonNFA* automaton; 11 | } 12 | - (instancetype)init: (NSArray*) states inputAlphabet:(NSArray *) inputAlphabet initialState:(NSString*) initialState finalStates:(NSArray*) finalStates transitions: (NSArray *) transitions { 13 | self = [super init]; 14 | auto statesSet = [self set: states]; 15 | auto inputAlphabetSet = [self set: inputAlphabet]; 16 | auto finalStatesSet = [self set: finalStates]; 17 | automaton = new automaton::EpsilonNFA(statesSet, inputAlphabetSet, [self stdString: initialState], finalStatesSet); 18 | 19 | [self setTransitions: transitions]; 20 | 21 | return self; 22 | } 23 | 24 | - (void)dealloc { 25 | delete automaton; 26 | } 27 | 28 | - (std::string)stdString: (NSString *) string { 29 | return std::string([string UTF8String]); 30 | } 31 | 32 | - (bool)simulate: (NSArray *) input { 33 | ext::vector inputVector = [self vector: input]; 34 | auto linearString = string::LinearString(automaton->getInputAlphabet(), inputVector); 35 | 36 | std::cout << *automaton << std::endl; 37 | 38 | return automaton::run::Accept::accept(*automaton, linearString); 39 | } 40 | 41 | - (void)setTransitions: (NSArray *) transitions { 42 | for (Transition_objc * transition in transitions) { 43 | for (NSString * symbolString in transition.symbols) { 44 | automaton->addTransition([self stdString: transition.fromState], [self stdString: symbolString], [self stdString: transition.toState]); 45 | } 46 | 47 | if (transition.isEpsilonIncluded) { 48 | automaton->addTransition([self stdString: transition.fromState], common::symbol_or_epsilon(), [self stdString: transition.toState]); 49 | } 50 | } 51 | } 52 | 53 | - (ext::set)set: (NSArray*) array { 54 | std::vector vector = {}; 55 | for (NSString * str in array) { 56 | vector.push_back([self stdString: str]); 57 | } 58 | return ext::set(ext::make_iterator_range(vector.begin(), vector.end())); 59 | } 60 | 61 | - (ext::vector)vector: (NSArray*) array { 62 | ext::vector vector = {}; 63 | for (NSString * str in array) { 64 | vector.push_back([self stdString: str]); 65 | } 66 | return vector; 67 | } 68 | 69 | - (NSArray *) array: (ext::set) set { 70 | NSMutableArray * array = [NSMutableArray array]; 71 | auto iterator = set.begin(); 72 | while (iterator != set.end()) { 73 | [array addObject: *iterator]; 74 | iterator++; 75 | } 76 | return array; 77 | } 78 | 79 | @end 80 | -------------------------------------------------------------------------------- /Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | func frameworks() -> [String] { 4 | [ 5 | "alib2abstraction", 6 | "alib2algo", 7 | "alib2common", 8 | "alib2data", 9 | "alib2measure", 10 | "alib2std", 11 | "alib2str", 12 | "alib2xml", 13 | ] 14 | } 15 | 16 | let project = Project( 17 | name: "AutomataEditor", 18 | targets: [ 19 | Target( 20 | name: "AutomataEditor", 21 | platform: .iOS, 22 | product: .app, 23 | bundleId: "marekfort.AutomataEditor", 24 | deploymentTarget: .iOS(targetVersion: "16.0", devices: .ipad), 25 | infoPlist: .file(path: "AutomataEditor/Info.plist"), 26 | sources: [ 27 | "AutomataEditor/**", 28 | "automata-editor-model/AutomataClassifier.mlmodel" 29 | ], 30 | resources: [ 31 | "AutomataEditor/*.xcassets", 32 | ], 33 | dependencies: [ 34 | .external(name: "ComposableArchitecture"), 35 | .external(name: "SwiftSplines"), 36 | .target(name: "SwiftAutomataLibrary"), 37 | ] 38 | ), 39 | Target( 40 | name: "AutomataEditorTests", 41 | platform: .iOS, 42 | product: .unitTests, 43 | bundleId: "marekfort.AutomataEditorTests", 44 | infoPlist: .default, 45 | sources: "AutomataEditorTests/**", 46 | dependencies: [ 47 | .target(name: "AutomataEditor"), 48 | .xctest, 49 | ] 50 | ), 51 | Target( 52 | name: "SwiftAutomataLibrary", 53 | platform: .iOS, 54 | product: .framework, 55 | bundleId: "marekfort.SwiftAutomataLibrary", 56 | deploymentTarget: .iOS(targetVersion: "14.0", devices: .ipad), 57 | infoPlist: .default, 58 | sources: "SwiftAutomataLibrary/**", 59 | headers: .headers( 60 | public: nil, 61 | private: "SwiftAutomataLibrary/**", 62 | project: nil 63 | ), 64 | dependencies: frameworks() 65 | .map { .xcframework(path: Path($0 + ".xcframework")) }, 66 | settings: .settings( 67 | base: [ 68 | "HEADER_SEARCH_PATHS": .array( 69 | frameworks().map { 70 | "$(SRCROOT)/automata-library/\($0)/src" 71 | } 72 | ), 73 | "SWIFT_OBJC_BRIDGING_HEADER": "$(SRCROOT)/SwiftAutomataLibrary/SwiftAutomataLibrary-BridgingHeader.h", 74 | "OTHER_CPLUSPLUSFLAGS": [ 75 | "$(OTHER_CFLAGS)", 76 | "'-std=gnu++17'", 77 | ] 78 | ], 79 | configurations: [], 80 | defaultSettings: .recommended 81 | ) 82 | ) 83 | ] 84 | ) 85 | -------------------------------------------------------------------------------- /Tuist/Dependencies/Lockfiles/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", 9 | "version" : "0.9.1" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-case-paths", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-case-paths", 16 | "state" : { 17 | "revision" : "bb436421f57269fbcfe7360735985321585a86e5", 18 | "version" : "0.10.1" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-clocks", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-clocks", 25 | "state" : { 26 | "revision" : "20b25ca0dd88ebfb9111ec937814ddc5a8880172", 27 | "version" : "0.2.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections", 34 | "state" : { 35 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 36 | "version" : "1.0.4" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-composable-architecture", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/pointfreeco/swift-composable-architecture", 43 | "state" : { 44 | "revision" : "c9259b5f74892690cb04a9a8088b4a1789b05a7d", 45 | "version" : "0.47.2" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-custom-dump", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 52 | "state" : { 53 | "revision" : "ead7d30cc224c3642c150b546f4f1080d1c411a8", 54 | "version" : "0.6.1" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-identified-collections", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 61 | "state" : { 62 | "revision" : "a08887de589e3829d488e0b4b707b2ca804b1060", 63 | "version" : "0.5.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swiftsplines", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/Bersaelor/SwiftSplines", 70 | "state" : { 71 | "revision" : "2288e9fbf3245f1b89b69c23bebd30461aa0f40c", 72 | "version" : "0.3.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swiftui-navigation", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/pointfreeco/swiftui-navigation", 79 | "state" : { 80 | "revision" : "a4a84e387c83a735e56d03bf9736d49a07b3c3ae", 81 | "version" : "0.4.3" 82 | } 83 | }, 84 | { 85 | "identity" : "xctest-dynamic-overlay", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 88 | "state" : { 89 | "revision" : "5a5457a744239896e9b0b03a8e1a5069c3e7b91f", 90 | "version" : "0.6.0" 91 | } 92 | } 93 | ], 94 | "version" : 2 95 | } 96 | -------------------------------------------------------------------------------- /AutomataEditor/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Automata Editor 9 | NSUbiquitousContainers 10 | 11 | iCloud.AutomataEditor 12 | 13 | NSUbiquitousContainerIsDocumentScopePublic 14 | 15 | NSUbiquitousContainerName 16 | Automata Editor 17 | NSUbiquitousContainerSupportedFolderLevels 18 | Any 19 | 20 | 21 | CFBundleDocumentTypes 22 | 23 | 24 | CFBundleTypeName 25 | Automaton 26 | LSHandlerRank 27 | Default 28 | LSItemContentTypes 29 | 30 | marekfort.AutomataEditor.automaton 31 | 32 | NSUbiquitousDocumentUserActivityType 33 | $(PRODUCT_BUNDLE_IDENTIFIER).automaton 34 | 35 | 36 | CFBundleExecutable 37 | $(EXECUTABLE_NAME) 38 | CFBundleIdentifier 39 | $(PRODUCT_BUNDLE_IDENTIFIER) 40 | CFBundleInfoDictionaryVersion 41 | 6.0 42 | CFBundleName 43 | $(PRODUCT_NAME) 44 | CFBundlePackageType 45 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 46 | CFBundleShortVersionString 47 | 1.0 48 | CFBundleVersion 49 | 3 50 | LSApplicationCategoryType 51 | 52 | LSRequiresIPhoneOS 53 | 54 | LSSupportsOpeningDocumentsInPlace 55 | 56 | UIApplicationSceneManifest 57 | 58 | UIApplicationSupportsMultipleScenes 59 | 60 | 61 | UIApplicationSupportsIndirectInputEvents 62 | 63 | UIFileSharingEnabled 64 | 65 | UILaunchScreen 66 | 67 | UIRequiredDeviceCapabilities 68 | 69 | armv7 70 | 71 | UIRequiresFullScreen 72 | 73 | UISupportedInterfaceOrientations 74 | 75 | UIInterfaceOrientationPortrait 76 | UIInterfaceOrientationLandscapeLeft 77 | UIInterfaceOrientationLandscapeRight 78 | 79 | UISupportedInterfaceOrientations~ipad 80 | 81 | UIInterfaceOrientationLandscapeLeft 82 | UIInterfaceOrientationLandscapeRight 83 | 84 | UIUserInterfaceStyle 85 | Dark 86 | UTExportedTypeDeclarations 87 | 88 | 89 | UTTypeConformsTo 90 | 91 | public.json 92 | 93 | UTTypeDescription 94 | Automaton created with AutomataEditor 95 | UTTypeIconFiles 96 | 97 | UTTypeIdentifier 98 | marekfort.AutomataEditor.automaton 99 | UTTypeTagSpecification 100 | 101 | public.filename-extension 102 | 103 | automaton 104 | 105 | 106 | 107 | 108 | UTImportedTypeDeclarations 109 | 110 | 111 | UTTypeConformsTo 112 | 113 | public.json 114 | 115 | UTTypeDescription 116 | Automaton 117 | UTTypeIconFiles 118 | 119 | UTTypeIdentifier 120 | marekfort.AutomataEditor.automaton 121 | UTTypeTagSpecification 122 | 123 | public.filename-extension 124 | 125 | json 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /AutomataEditor/CanvasView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import PencilKit 3 | 4 | enum Tool: String, Codable { 5 | case pen 6 | case eraser 7 | 8 | fileprivate var pkTool: PKTool { 9 | switch self { 10 | case .pen: 11 | return PKInkingTool(.pen, color: .black, width: 15) 12 | case .eraser: 13 | return PKEraserTool(.vector) 14 | } 15 | } 16 | } 17 | 18 | /// Forwards touches to PKCanvasView if the hit test returns the overlay view (and not e.g. one of the drag buttons) 19 | final class ContentView: UIView { 20 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 21 | guard let view = super.hitTest(point, with: event) else { return nil } 22 | if subviews.contains(view) { 23 | return subviews.first(where: { $0 is PKCanvasView })?.hitTest(point, with: event) 24 | } 25 | return view 26 | } 27 | } 28 | 29 | struct CanvasView: UIViewRepresentable { 30 | var tool: Tool 31 | let strokesChanged: ([Stroke]) -> Void 32 | let currentVisibleScrollViewRectChanged: (CGRect) -> Void 33 | @ViewBuilder var view: Content 34 | 35 | func makeUIView(context: Context) -> UIScrollView { 36 | let scrollView = UIScrollView() 37 | scrollView.delegate = context.coordinator 38 | scrollView.minimumZoomScale = max(UIScreen.main.bounds.width / 4000, UIScreen.main.bounds.height / 4000) 39 | scrollView.maximumZoomScale = 2.5 40 | 41 | let contentView = ContentView() 42 | contentView.frame.size = CGSize(width: 4000, height: 4000) 43 | scrollView.addSubview(contentView) 44 | 45 | let canvasView = PKCanvasView() 46 | canvasView.frame.size = CGSize(width: 4000, height: 4000) 47 | canvasView.delegate = context.coordinator 48 | canvasView.drawingGestureRecognizer.delegate = context.coordinator 49 | canvasView.drawingPolicy = .default 50 | canvasView.tool = tool.pkTool 51 | contentView.addSubview(canvasView) 52 | context.coordinator.canvasView = canvasView 53 | 54 | context.coordinator.viewForZooming = contentView 55 | 56 | context.coordinator.hostingController = UIHostingController(rootView: view) 57 | 58 | guard let overlayView = context.coordinator.hostingController?.view else { return scrollView } 59 | overlayView.backgroundColor = .clear 60 | overlayView.frame = canvasView.frame 61 | contentView.addSubview(overlayView) 62 | 63 | return scrollView 64 | } 65 | 66 | func makeCoordinator() -> CanvasCoordinator { 67 | CanvasCoordinator(self) 68 | } 69 | 70 | func updateUIView(_ scrollView: UIScrollView, context: Context) { 71 | let canvasView = context.coordinator.canvasView! 72 | canvasView.tool = tool.pkTool 73 | 74 | context.coordinator.hostingController?.rootView = view 75 | } 76 | } 77 | 78 | // MARK: - Coordinator 79 | 80 | final class CanvasCoordinator: NSObject, PKCanvasViewDelegate, UIGestureRecognizerDelegate where Content: View { 81 | private let parent: CanvasView 82 | private var shouldUpdateStrokes = false 83 | var viewForZooming: UIView? 84 | var canvasView: PKCanvasView! 85 | var hostingController: UIHostingController! 86 | 87 | init(_ parent: CanvasView) { 88 | self.parent = parent 89 | } 90 | 91 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { 92 | viewForZooming 93 | } 94 | 95 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 96 | let visibleRect = scrollView.convert(scrollView.bounds, to: viewForZooming) 97 | parent.currentVisibleScrollViewRectChanged(visibleRect) 98 | } 99 | 100 | func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { 101 | guard shouldUpdateStrokes else { return } 102 | shouldUpdateStrokes = false 103 | parent.strokesChanged(canvasView.drawing.strokes.map(Stroke.init)) 104 | canvasView.drawing.strokes = [] 105 | } 106 | 107 | func canvasViewDidEndUsingTool(_ canvasView: PKCanvasView) { 108 | shouldUpdateStrokes = true 109 | } 110 | } 111 | 112 | -------------------------------------------------------------------------------- /AutomataEditor/Overview/OverviewView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Vision 3 | import PencilKit 4 | import ComposableArchitecture 5 | 6 | struct OverviewView: View { 7 | let store: StoreOf 8 | 9 | var body: some View { 10 | WithViewStore(store) { viewStore in 11 | NavigationStack { 12 | ScrollView { 13 | OverviewGrid(store: store) 14 | } 15 | .onAppear { 16 | viewStore.send(.loadAutomata) 17 | } 18 | .navigationBarTitle("My Automata", displayMode: .inline) 19 | .alert( 20 | "New automaton", 21 | isPresented: viewStore.binding( 22 | get: \.isAlertForNewAutomatonNamePresented, 23 | send: OverviewFeature.Action.isAlertForNewAutomatonNamePresentedChanged 24 | ), 25 | actions: { 26 | TextField( 27 | "Automaton name", 28 | text: viewStore.binding( 29 | get: \.automatonName, 30 | send: OverviewFeature.Action.automatonNameChanged 31 | ) 32 | ) 33 | Button( 34 | "OK", 35 | action: { 36 | viewStore.send(.createNewAutomaton) 37 | } 38 | ) 39 | Button( 40 | "Cancel", 41 | role: .cancel, 42 | action: { 43 | viewStore.send(.isAlertForNewAutomatonNamePresentedChanged(false)) 44 | } 45 | ) 46 | }, 47 | message: { 48 | Text("Name your new automaton.") 49 | } 50 | ) 51 | .toolbar { 52 | if (viewStore.state.isSelectingFiles) { 53 | ToolbarItemGroup(placement: .navigationBarLeading) { 54 | Button(action: { viewStore.send(.removeSelectedFiles) }) { 55 | Image(systemName: "trash") 56 | } 57 | } 58 | ToolbarItemGroup { 59 | Button("Done") { 60 | viewStore.send(.doneSelectingFiles) 61 | } 62 | .bold() 63 | } 64 | } else { 65 | ToolbarItemGroup(placement: .navigationBarLeading) { 66 | Button("Show Files") { 67 | viewStore.send(.isDocumentSheetPresentedChanged(true)) 68 | } 69 | .sheet( 70 | isPresented: viewStore.binding( 71 | get: \.isDocumentSheetPresented, 72 | send: { .isDocumentSheetPresentedChanged($0) } 73 | ) 74 | ) { 75 | // Things to do when the screen is dismissed 76 | } content: { 77 | DocumentPicker(selectedDocument: { viewStore.send(.selectedAutomaton($0)) } ) 78 | } 79 | } 80 | ToolbarItemGroup { 81 | Button("Help") { 82 | viewStore.send(.isHelpPresentedChanged(true)) 83 | } 84 | .sheet( 85 | isPresented: viewStore.binding( 86 | get: \.isHelpPresented, 87 | send: { .isHelpPresentedChanged($0) } 88 | ) 89 | ) { 90 | HelpView() 91 | } 92 | Button("Select") { 93 | viewStore.send(.selectFiles) 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/EditorView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Vision 3 | import PencilKit 4 | import ComposableArchitecture 5 | 6 | struct EditorView: View { 7 | let store: StoreOf 8 | 9 | var body: some View { 10 | WithViewStore(store) { viewStore in 11 | GeometryReader { geometry in 12 | ZStack { 13 | CanvasView( 14 | tool: viewStore.state.tool, 15 | strokesChanged: { viewStore.send(.strokesChanged($0)) }, 16 | currentVisibleScrollViewRectChanged: { viewStore.send(.currentVisibleScrollViewRectChanged($0)) } 17 | ) { 18 | ZStack { 19 | TransitionsView( 20 | transitions: viewStore.transitions, 21 | toggleEpsilonInclusion: { viewStore.send(.toggleEpsilonInclusion($0)) }, 22 | transitionSymbolRemoved: { viewStore.send(.transitionSymbolRemoved($0, $1)) }, 23 | transitionSymbolChanged: { viewStore.send(.transitionSymbolChanged($0, $1)) }, 24 | transitionSymbolAdded: { viewStore.send(.transitionSymbolAdded($0)) }, 25 | transitionRemoved: { viewStore.send(.transitionRemoved($0)) }, 26 | transitionDragged: { 27 | viewStore.send(.transitionFlexPointChanged($0, $1)) 28 | }, 29 | transitionFinishedDragging: { 30 | viewStore.send(.transitionFlexPointFinishedDragging($0, $1)) 31 | }, 32 | mode: viewStore.mode 33 | ) 34 | AutomatonStatesView( 35 | automatonStates: viewStore.automatonStates, 36 | stateSymbolChanged: { viewStore.send(.stateSymbolChanged($0, $1)) }, 37 | automatonStateDragged: { viewStore.send(.stateDragPointChanged($0, $1)) }, 38 | automatonStateFinishedDragging: { viewStore.send(.stateDragPointFinishedDragging($0, $1)) }, 39 | automatonStateRemoved: { viewStore.send(.automatonStateRemoved($0)) }, 40 | selectedStateForTransition: { viewStore.send(.selectedStateForTransition($0)) }, 41 | selectedStateForCycle: { viewStore.send(.selectedStateForCycle($0)) }, 42 | selectedFinalState: { viewStore.send(.selectedFinalState($0)) }, 43 | selectedInitialState: { viewStore.send(.selectedInitialState($0)) }, 44 | currentlySelectedStateForTransition: viewStore.currentlySelectedStateForTransition, 45 | mode: viewStore.mode, 46 | initialStates: viewStore.initialStates 47 | ) 48 | } 49 | } 50 | VStack { 51 | Button() { 52 | viewStore.send(.dismissToast) 53 | } label: { 54 | if viewStore.isAutomatonOutputVisible { 55 | ToastView( 56 | image: viewStore.automatonOutput.image, 57 | imageColor: viewStore.automatonOutput.imageColor, 58 | title: viewStore.automatonOutput.title, 59 | subtitle: viewStore.automatonOutput.subtitle 60 | ) 61 | .transition(AnyTransition.move(edge: .top)) 62 | .animation(.spring(), value: viewStore.automatonOutput.title) 63 | } 64 | } 65 | .animation(.spring(), value: viewStore.isAutomatonOutputVisible) 66 | Spacer() 67 | } 68 | AutomatonInput(viewStore: viewStore) 69 | } 70 | .toolbar { 71 | EditorToolbar(viewStore: viewStore) 72 | } 73 | .onChange(of: viewStore.state, perform: { viewStore.send(.stateUpdated($0)) }) 74 | .onAppear { viewStore.send(.viewSizeChanged(geometry.size)) } 75 | .alert( 76 | "Clear automaton", 77 | isPresented: viewStore.binding(get: \.isClearAlertPresented, send: { _ in .clearAlertDismissed }) 78 | ) { 79 | Button("Delete", role: .destructive) { 80 | viewStore.send(.clear) 81 | } 82 | } message: { 83 | Text("Do you really want to clear this automaton? This can't be undone.") 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | private extension EditorFeature.AutomatonOutput { 91 | var image: String { 92 | switch self { 93 | case .success: 94 | return "checkmark.circle" 95 | case .failure: 96 | return "xmark.circle" 97 | } 98 | } 99 | 100 | var imageColor: Color { 101 | switch self { 102 | case .success: 103 | return .green 104 | case .failure: 105 | return .red 106 | } 107 | } 108 | 109 | var title: String { 110 | switch self { 111 | case .success: 112 | return "Input accepted" 113 | case .failure: 114 | return "Input rejected" 115 | } 116 | } 117 | 118 | var subtitle: String? { 119 | switch self { 120 | case .success: 121 | return nil 122 | case let .failure(reason): 123 | return reason 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /AutomataEditor/Overview/OverviewStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ComposableArchitecture 3 | 4 | struct AutomatonFile: Equatable, Identifiable { 5 | let url: URL 6 | let name: String 7 | 8 | var id: URL { 9 | url 10 | } 11 | } 12 | 13 | struct OverviewFeature: ReducerProtocol { 14 | struct State: Equatable { 15 | var isDocumentSheetPresented = false 16 | var isEditorPresented = false 17 | var editor: EditorFeature.State? 18 | var automatonFiles: [AutomatonFile] = [] 19 | var selectedAutomatonURL: URL? 20 | var isAlertForNewAutomatonNamePresented = false 21 | var isHelpPresented = false 22 | var automatonName = "" 23 | var isSelectingFiles = false 24 | var selectedAutomatonFileIDs: [AutomatonFile.ID] = [] 25 | } 26 | enum Action: Equatable { 27 | case isDocumentSheetPresentedChanged(Bool) 28 | case isEditorPresentedChanged(Bool) 29 | case editor(EditorFeature.Action) 30 | case createNewAutomaton 31 | case selectedAutomaton(URL) 32 | case loadedAutomaton(URL, AutomatonDocument) 33 | case loadAutomata 34 | case loadedAutomata([URL]) 35 | case automatonSaved 36 | case automatonNameChanged(String) 37 | case isAlertForNewAutomatonNamePresentedChanged(Bool) 38 | case selectFiles 39 | case doneSelectingFiles 40 | case removeSelectedFiles 41 | case removedSelectedFiles 42 | case isHelpPresentedChanged(Bool) 43 | } 44 | 45 | @Dependency(\.automatonDocumentService) var automatonDocumentService 46 | 47 | var body: some ReducerProtocol { 48 | Reduce { state, action in 49 | switch action { 50 | case .selectFiles: 51 | state.isSelectingFiles = true 52 | return .none 53 | case .doneSelectingFiles: 54 | state.isSelectingFiles = false 55 | state.selectedAutomatonFileIDs = [] 56 | return .none 57 | case let .isDocumentSheetPresentedChanged(isDocumentSheetPresented): 58 | state.isDocumentSheetPresented = isDocumentSheetPresented 59 | return .none 60 | case let .selectedAutomaton(url): 61 | if state.isSelectingFiles { 62 | if state.selectedAutomatonFileIDs.contains(url) { 63 | state.selectedAutomatonFileIDs.removeAll(where: { $0 == url }) 64 | } else { 65 | state.selectedAutomatonFileIDs.append(url) 66 | } 67 | 68 | return .none 69 | } 70 | state.automatonName = "" 71 | return .task { 72 | let automatonDocument = try await automatonDocumentService.readAutomaton(url) 73 | return .loadedAutomaton(url, automatonDocument) 74 | } 75 | case .removeSelectedFiles: 76 | let selectedFileURLs = state.selectedAutomatonFileIDs 77 | return .task { 78 | try automatonDocumentService.deleteAutomata(selectedFileURLs) 79 | return .removedSelectedFiles 80 | } 81 | 82 | case .removedSelectedFiles: 83 | state.isSelectingFiles = false 84 | state.automatonFiles.removeAll(where: { state.selectedAutomatonFileIDs.contains($0.id) }) 85 | state.selectedAutomatonFileIDs = [] 86 | return .none 87 | case let .loadedAutomaton(url, automaton): 88 | state.editor = EditorFeature.State( 89 | automatonURL: url, 90 | id: automaton.id, 91 | automatonStatesDict: automaton.automatonStates, 92 | transitionsDict: automaton.transitions 93 | ) 94 | state.isEditorPresented = true 95 | return .none 96 | case let .isEditorPresentedChanged(isEditorPresented): 97 | state.isEditorPresented = isEditorPresented 98 | return .none 99 | case let .editor(action): 100 | switch action { 101 | case let .stateUpdated(editorState): 102 | return .task { 103 | try automatonDocumentService.saveAutomaton( 104 | editorState.automatonURL, 105 | AutomatonDocument( 106 | id: editorState.id, 107 | transitions: editorState.transitionsDict, 108 | automatonStates: editorState.automatonStatesDict 109 | ) 110 | ) 111 | 112 | return .automatonSaved 113 | } 114 | default: 115 | return .none 116 | } 117 | case .automatonSaved: 118 | return .none 119 | case let .isAlertForNewAutomatonNamePresentedChanged(value): 120 | state.isAlertForNewAutomatonNamePresented = value 121 | return .none 122 | case .createNewAutomaton: 123 | let automatonName = state.automatonName 124 | return .task { 125 | let url = try await automatonDocumentService.createNewAutomaton(automatonName) 126 | return .selectedAutomaton(url) 127 | } 128 | case let .automatonNameChanged(name): 129 | state.automatonName = name 130 | return .none 131 | case .loadAutomata: 132 | return .task { 133 | let urls = try await automatonDocumentService.loadAutomata() 134 | return .loadedAutomata(urls) 135 | } 136 | case let .loadedAutomata(urls): 137 | state.automatonFiles = urls.map { url in 138 | AutomatonFile(url: url, name: String(url.lastPathComponent.split(separator: ".").first ?? "")) 139 | } 140 | return .none 141 | case let .isHelpPresentedChanged(value): 142 | state.isHelpPresented = value 143 | return .none 144 | } 145 | } 146 | .ifLet(\.editor, action: CasePath({ action in 147 | Action.editor(action) 148 | }), then: { 149 | EditorFeature() 150 | } as () -> EditorFeature) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /AutomataEditor/AutomatonTransition.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct AutomatonTransition: Equatable, Identifiable, Codable { 4 | enum TransitionType: Equatable, Hashable, Codable { 5 | case cycle(CGPoint, center: CGPoint, radians: CGFloat) 6 | case regular(startPoint: CGPoint, tipPoint: CGPoint, flexPoint: CGPoint) 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case point 10 | case center 11 | case radians 12 | case startPoint 13 | case tipPoint 14 | case flexPoint 15 | case type 16 | } 17 | 18 | private enum CaseType: String, Codable { 19 | case cycle 20 | case regular 21 | } 22 | 23 | init(from decoder: Decoder) throws { 24 | let container = try decoder.container(keyedBy: CodingKeys.self) 25 | let type = try container.decode(CaseType.self, forKey: .type) 26 | switch type { 27 | case .cycle: 28 | let point = try container.decode(CGPoint.self, forKey: .point) 29 | let center = try container.decode(CGPoint.self, forKey: .center) 30 | let radians = try container.decode(CGFloat.self, forKey: .radians) 31 | self = .cycle(point, center: center, radians: radians) 32 | case .regular: 33 | let startPoint = try container.decode(CGPoint.self, forKey: .startPoint) 34 | let tipPoint = try container.decode(CGPoint.self, forKey: .tipPoint) 35 | let flexPoint = try container.decode(CGPoint.self, forKey: .flexPoint) 36 | self = .regular(startPoint: startPoint, tipPoint: tipPoint, flexPoint: flexPoint) 37 | } 38 | } 39 | 40 | func encode(to encoder: Encoder) throws { 41 | var container = encoder.container(keyedBy: CodingKeys.self) 42 | switch self { 43 | case let .cycle(point, center: center, radians: radians): 44 | try container.encode(CaseType.cycle, forKey: .type) 45 | try container.encode(point, forKey: .point) 46 | try container.encode(center, forKey: .center) 47 | try container.encode(radians, forKey: .radians) 48 | case let .regular(startPoint: startPoint, tipPoint: tipPoint, flexPoint: flexPoint): 49 | try container.encode(CaseType.regular, forKey: .type) 50 | try container.encode(startPoint, forKey: .startPoint) 51 | try container.encode(tipPoint, forKey: .tipPoint) 52 | try container.encode(flexPoint, forKey: .flexPoint) 53 | } 54 | } 55 | } 56 | 57 | let id: String 58 | var startState: AutomatonState.ID? 59 | var endState: AutomatonState.ID? 60 | /// Symbol currently being written 61 | var currentSymbol: String = "" 62 | var symbols: [String] = [] 63 | var includesEpsilon: Bool = false 64 | var type: TransitionType 65 | /// Current flex point 66 | /// Needed for gesture 67 | /// Might not always correspond to the value if a gesture is currently underway 68 | var currentFlexPoint: CGPoint? = nil 69 | 70 | var scribblePosition: CGPoint? { 71 | /// Do not show editor for initial transition 72 | if endState != nil, startState == nil { return nil } 73 | switch type { 74 | case let .cycle(point, center: center, radians: _): 75 | let vector = Vector(center, point) 76 | return vector.rotated(by: .pi / 13).point(distance: 95, other: point) 77 | case let .regular(startPoint: _, tipPoint: _, flexPoint: flexPoint): 78 | return CGPoint(x: flexPoint.x, y: flexPoint.y - 70) 79 | } 80 | } 81 | 82 | var isInitialTransition: Bool { 83 | startState == nil && endState != nil 84 | } 85 | 86 | var startPoint: CGPoint? { 87 | get { 88 | switch type { 89 | case .cycle: 90 | return nil 91 | case let .regular( 92 | startPoint: startPoint, 93 | tipPoint: _, 94 | flexPoint: _ 95 | ): 96 | return startPoint 97 | } 98 | } 99 | set { 100 | guard let newValue = newValue else { return } 101 | switch type { 102 | case .cycle: 103 | break 104 | case let .regular( 105 | startPoint: _, 106 | tipPoint: tipPoint, 107 | flexPoint: flexPoint 108 | ): 109 | type = .regular( 110 | startPoint: newValue, 111 | tipPoint: tipPoint, 112 | flexPoint: flexPoint 113 | ) 114 | } 115 | } 116 | } 117 | 118 | var tipPoint: CGPoint? { 119 | get { 120 | switch type { 121 | case .cycle: 122 | return nil 123 | case let .regular( 124 | startPoint: _, 125 | tipPoint: tipPoint, 126 | flexPoint: _ 127 | ): 128 | return tipPoint 129 | } 130 | } 131 | set { 132 | guard let newValue = newValue else { return } 133 | switch type { 134 | case .cycle: 135 | break 136 | case let .regular( 137 | startPoint: startPoint, 138 | tipPoint: _, 139 | flexPoint: flexPoint 140 | ): 141 | type = .regular( 142 | startPoint: startPoint, 143 | tipPoint: newValue, 144 | flexPoint: flexPoint 145 | ) 146 | } 147 | } 148 | } 149 | 150 | var flexPoint: CGPoint? { 151 | get { 152 | switch type { 153 | case .cycle: 154 | return nil 155 | case let .regular( 156 | startPoint: _, 157 | tipPoint: _, 158 | flexPoint: flexPoint 159 | ): 160 | return flexPoint 161 | } 162 | } 163 | set { 164 | guard let newValue = newValue else { return } 165 | switch type { 166 | case .cycle: 167 | break 168 | case let .regular( 169 | startPoint: startPoint, 170 | tipPoint: tipPoint, 171 | flexPoint: _ 172 | ): 173 | type = .regular( 174 | startPoint: startPoint, 175 | tipPoint: tipPoint, 176 | flexPoint: newValue 177 | ) 178 | } 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/AutomatonStatesView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AutomatonStateEditingView: View { 4 | let automatonState: AutomatonState 5 | let automatonStateDragged: ((AutomatonState.ID, CGPoint) -> Void) 6 | let automatonStateFinishedDragging: ((AutomatonState.ID, CGPoint) -> Void) 7 | 8 | var body: some View { 9 | ZStack { 10 | Circle() 11 | .fill(.blue) 12 | .frame(width: 30) 13 | Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") 14 | .frame(width: 25) 15 | } 16 | .padding(15) 17 | // Hack to force the view to render 18 | // Otherwise, the PKCanvasView is returned in the hitTest method in CanvasView 19 | .background(Color.black.opacity(0.00001)) 20 | .position(automatonState.dragPoint) 21 | .offset( 22 | x: automatonState.currentDragPoint.x - automatonState.dragPoint.x, 23 | y: automatonState.currentDragPoint.y - automatonState.dragPoint.y 24 | ) 25 | .gesture( 26 | DragGesture() 27 | .onChanged { value in 28 | automatonStateDragged( 29 | automatonState.id, 30 | CGPoint( 31 | x: automatonState.dragPoint.x + value.translation.width, 32 | y: automatonState.dragPoint.y + value.translation.height 33 | ) 34 | ) 35 | } 36 | .onEnded { value in 37 | automatonStateFinishedDragging( 38 | automatonState.id, 39 | CGPoint( 40 | x: automatonState.dragPoint.x + value.translation.width, 41 | y: automatonState.dragPoint.y + value.translation.height 42 | ) 43 | ) 44 | } 45 | ) 46 | } 47 | } 48 | 49 | /// View that holds all automaton states. 50 | struct AutomatonStatesView: View { 51 | var automatonStates: [AutomatonState] 52 | let stateSymbolChanged: ((AutomatonState.ID, String) -> Void) 53 | let automatonStateDragged: ((AutomatonState.ID, CGPoint) -> Void) 54 | let automatonStateFinishedDragging: ((AutomatonState.ID, CGPoint) -> Void) 55 | let automatonStateRemoved: ((AutomatonState.ID) -> Void) 56 | let selectedStateForTransition: ((AutomatonState.ID) -> Void) 57 | let selectedStateForCycle: ((AutomatonState.ID) -> Void) 58 | let selectedFinalState: ((AutomatonState.ID) -> Void) 59 | let selectedInitialState: ((AutomatonState.ID) -> Void) 60 | let currentlySelectedStateForTransition: AutomatonState.ID? 61 | let mode: EditorFeature.Mode 62 | let initialStates: [AutomatonState] 63 | 64 | var body: some View { 65 | ForEach(automatonStates) { automatonState in 66 | TextField( 67 | "", 68 | text: Binding( 69 | get: { automatonState.name }, 70 | set: { stateSymbolChanged(automatonState.id, $0) } 71 | ) 72 | ) 73 | .textInputAutocapitalization(TextInputAutocapitalization.never) 74 | .autocorrectionDisabled(true) 75 | .multilineTextAlignment(.center) 76 | .padding(2) 77 | .overlay( 78 | RoundedRectangle(cornerRadius: 5) 79 | .stroke(Color.white, lineWidth: 2) 80 | ) 81 | .frame(width: 50, height: 30) 82 | .position(automatonState.scribblePosition) 83 | 84 | Circle() 85 | .strokeBorder(.white, lineWidth: 4) 86 | .frame(width: automatonState.radius * 2, height: automatonState.radius * 2) 87 | .position(automatonState.center) 88 | 89 | if (automatonState.isFinalState) { 90 | Circle() 91 | .strokeBorder(.white, lineWidth: 4) 92 | .frame(width: automatonState.radius * 2 - 20, height: automatonState.radius * 2 - 20) 93 | .position(automatonState.center) 94 | } 95 | 96 | switch mode { 97 | case .addingTransition: 98 | AddTransitionView( 99 | point: automatonState.dragPoint, 100 | isSelected: automatonState.id == currentlySelectedStateForTransition, 101 | selected: { selectedStateForTransition(automatonState.id) } 102 | ) 103 | case .addingCycle: 104 | Button(action: { selectedStateForCycle(automatonState.id) }) { 105 | ZStack { 106 | Circle() 107 | .strokeBorder(.blue, lineWidth: 2) 108 | } 109 | } 110 | .background(Color.black.opacity(0.00001)) 111 | .frame(width: 30) 112 | .position(automatonState.dragPoint) 113 | case .addingFinalState: 114 | if !automatonState.isFinalState { 115 | Button(action: { selectedFinalState(automatonState.id) }) { 116 | Circle() 117 | .strokeBorder(.blue, lineWidth: 2) 118 | } 119 | .frame(width: 30) 120 | .padding(15) 121 | .background(Color.black.opacity(0.00001)) 122 | .position(automatonState.dragPoint) 123 | } 124 | case .addingInitialState: 125 | if !initialStates.map(\.id).contains(automatonState.id) { 126 | Button(action: { selectedInitialState(automatonState.id) }) { 127 | Circle() 128 | .strokeBorder(.blue, lineWidth: 2) 129 | } 130 | .frame(width: 30) 131 | .padding(15) 132 | .background(Color.black.opacity(0.00001)) 133 | .position(automatonState.dragPoint) 134 | } 135 | case .erasing: 136 | Button(action: { automatonStateRemoved(automatonState.id) }) { 137 | ZStack { 138 | Circle() 139 | .fill(.red) 140 | .frame(width: 30) 141 | Image(systemName: "minus") 142 | .foregroundColor(.white) 143 | .frame(width: 25) 144 | } 145 | } 146 | .padding(15) 147 | .background(Color.black.opacity(0.00001)) 148 | .position(automatonState.dragPoint) 149 | case .editing: 150 | AutomatonStateEditingView( 151 | automatonState: automatonState, 152 | automatonStateDragged: automatonStateDragged, 153 | automatonStateFinishedDragging: automatonStateFinishedDragging 154 | ) 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /AutomataEditor/CGPoint+Shape.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import SwiftUI 3 | 4 | extension Path { 5 | mutating func cycle( 6 | point: CGPoint, 7 | center: CGPoint 8 | ) { 9 | let vector = Vector(point, center) 10 | let topPoint = vector.rotated(by: .pi / 11).point(distance: -100, other: point) 11 | let radius = sqrt(pow(center.x - point.x, 2) + pow(center.y - point.y, 2)) 12 | let finalPoint = Vector(center, point).rotated(by: .pi / 8).point(distance: radius, other: center) 13 | move(to: point) 14 | addQuadCurve( 15 | to: finalPoint, 16 | control: topPoint 17 | ) 18 | 19 | arrow(startPoint: Vector(topPoint, finalPoint).point(distance: 95, other: topPoint), tipPoint: finalPoint, arrowSpan: 30) 20 | } 21 | 22 | mutating func arrow( 23 | startPoint: CGPoint, 24 | tipPoint: CGPoint, 25 | flexPoint: CGPoint? = nil, 26 | arrowSpan: CGFloat = 60 27 | ) { 28 | let vector = Vector(flexPoint ?? startPoint, tipPoint) 29 | let anchorPoint = vector.point(distance: -arrowSpan / 3, other: tipPoint) 30 | let perpendicularVector = vector.rotated(by: .pi / 2) 31 | let topPoint = perpendicularVector.point(distance: -arrowSpan / 2, other: anchorPoint) 32 | let bottomPoint = perpendicularVector.point(distance: arrowSpan / 2, other: anchorPoint) 33 | 34 | move(to: startPoint) 35 | if let flexPoint = flexPoint { 36 | hermiteSpline(for: [startPoint, flexPoint, tipPoint], closed: false) 37 | } else { 38 | addLine(to: tipPoint) 39 | } 40 | move(to: tipPoint) 41 | addLine(to: topPoint) 42 | move(to: tipPoint) 43 | addLine(to: bottomPoint) 44 | move(to: bottomPoint) 45 | } 46 | 47 | // Partly taken from: https://stackoverflow.com/a/34583708/4975152 48 | mutating func hermiteSpline(for points: [CGPoint], closed: Bool) { 49 | guard points.count > 1 else { return } 50 | let numberOfCurves = closed ? points.count : points.count - 1 51 | 52 | var previousPoint: CGPoint? = closed ? points.last : nil 53 | var currentPoint: CGPoint = points[0] 54 | var nextPoint: CGPoint? = points[1] 55 | 56 | move(to: currentPoint) 57 | 58 | for index in 0 ..< numberOfCurves { 59 | let endPt = nextPoint! 60 | 61 | var mx: CGFloat 62 | var my: CGFloat 63 | 64 | if previousPoint != nil { 65 | mx = (nextPoint!.x - currentPoint.x) * 0.5 + (currentPoint.x - previousPoint!.x)*0.5 66 | my = (nextPoint!.y - currentPoint.y) * 0.5 + (currentPoint.y - previousPoint!.y)*0.5 67 | } else { 68 | mx = (nextPoint!.x - currentPoint.x) * 0.5 69 | my = (nextPoint!.y - currentPoint.y) * 0.5 70 | } 71 | 72 | let ctrlPt1 = CGPoint(x: currentPoint.x + mx / 3.0, y: currentPoint.y + my / 3.0) 73 | 74 | previousPoint = currentPoint 75 | currentPoint = nextPoint! 76 | let nextIndex = index + 2 77 | if closed { 78 | nextPoint = points[nextIndex % points.count] 79 | } else { 80 | nextPoint = nextIndex < points.count ? points[nextIndex % points.count] : nil 81 | } 82 | 83 | if nextPoint != nil { 84 | mx = (nextPoint!.x - currentPoint.x) * 0.5 + (currentPoint.x - previousPoint!.x) * 0.5 85 | my = (nextPoint!.y - currentPoint.y) * 0.5 + (currentPoint.y - previousPoint!.y) * 0.5 86 | } 87 | else { 88 | mx = (currentPoint.x - previousPoint!.x) * 0.5 89 | my = (currentPoint.y - previousPoint!.y) * 0.5 90 | } 91 | 92 | let ctrlPt2 = CGPoint(x: currentPoint.x - mx / 3.0, y: currentPoint.y - my / 3.0) 93 | 94 | addCurve(to: endPt, control1: ctrlPt1, control2: ctrlPt2) 95 | } 96 | } 97 | } 98 | 99 | extension Array where Element == CGPoint { 100 | static func circle( 101 | center: CGPoint, 102 | radius: CGFloat 103 | ) -> Self { 104 | stride(from: CGFloat(0), to: 362, by: 2).map { index in 105 | let radians = index * CGFloat.pi / 180 106 | 107 | return CGPoint( 108 | x: CGFloat(center.x + radius * cos(radians)), 109 | y: CGFloat(center.y + radius * sin(radians)) 110 | ) 111 | } 112 | } 113 | 114 | static func cycle( 115 | _ point: CGPoint, 116 | center: CGPoint 117 | ) -> Self { 118 | let vector = Vector(point, center) 119 | let topPoint = vector.point(distance: -70, other: point) 120 | let startToTopVector = Vector(point, topPoint) 121 | let finalPoint = startToTopVector.rotated(by: .pi * 0.4).point(distance: 5, other: point) 122 | return [ 123 | point, 124 | startToTopVector.rotated(by: -.pi / 3).point(distance: 10, other: point), 125 | startToTopVector.rotated(by: -.pi / 4).point(distance: 40, other: point), 126 | topPoint, 127 | startToTopVector.rotated(by: .pi / 4).point(distance: 40, other: point), 128 | startToTopVector.rotated(by: .pi / 3).point(distance: 10, other: point), 129 | ] 130 | // + .arrow( 131 | // startPoint: finalPoint, 132 | // tipPoint: point, 133 | // arrowSpan: 30 134 | // ) 135 | } 136 | } 137 | 138 | enum Geometry { 139 | struct Circle { 140 | let center: CGPoint 141 | let radius: CGFloat 142 | } 143 | 144 | static func intersections( 145 | pointA: CGPoint, 146 | pointB: CGPoint, 147 | circle: Geometry.Circle 148 | ) -> (CGPoint, CGPoint) { 149 | let vector = Vector(pointA, pointB) 150 | let a: CGFloat = pow(vector.x, 2) + pow(vector.y, 2) 151 | let b: CGFloat = 2 * vector.x * (pointA.x - circle.center.x) + 2 * vector.y * (pointA.y - circle.center.y) 152 | let c: CGFloat = pow(pointA.x - circle.center.x, 2) + pow(pointA.y - circle.center.y, 2) - pow(circle.radius, 2) 153 | let tOne: CGFloat = (-b + sqrt(pow(b, 2) - 4 * a * c)) / (2 * a) 154 | let tTwo: CGFloat = (-b - sqrt(pow(b, 2) - 4 * a * c)) / (2 * a) 155 | 156 | return ( 157 | CGPoint(x: vector.x * tOne + pointA.x, y: vector.y * tOne + pointA.y), 158 | CGPoint(x: vector.x * tTwo + pointA.x, y: vector.y * tTwo + pointA.y) 159 | ) 160 | } 161 | } 162 | 163 | /// Inspired from: https://github.com/nicklockwood/VectorMath/blob/master/VectorMath/VectorMath.swift 164 | struct Vector: Hashable { 165 | var x: CGFloat 166 | var y: CGFloat 167 | 168 | typealias Scalar = CGFloat 169 | 170 | init(x: Scalar, y: Scalar) { 171 | self.x = x 172 | self.y = y 173 | } 174 | 175 | init(_ x: Scalar, _ y: Scalar) { 176 | self.init(x: x, y: y) 177 | } 178 | 179 | init(_ pointA: CGPoint, _ pointB: CGPoint) { 180 | self.init(pointB.x - pointA.x, pointB.y - pointA.y) 181 | } 182 | 183 | var lengthSquared: Scalar { 184 | return x * x + y * y 185 | } 186 | 187 | func point(distance: Scalar, other point: CGPoint) -> CGPoint { 188 | CGPoint( 189 | x: point.x + distance * normalized().x, 190 | y: point.y + distance * normalized().y 191 | ) 192 | } 193 | 194 | func rotated(by radians: Scalar) -> Vector { 195 | let cs = cos(radians) 196 | let sn = sin(radians) 197 | return Vector(x * cs - y * sn, x * sn + y * cs) 198 | } 199 | 200 | func normalized() -> Vector { 201 | let lengthSquared = self.lengthSquared 202 | if lengthSquared ~= 0 || lengthSquared ~= 1 { 203 | return self 204 | } 205 | return self / sqrt(lengthSquared) 206 | } 207 | 208 | func angle(with v: Vector) -> Scalar { 209 | if self == v { 210 | return 0 211 | } 212 | 213 | let t1 = normalized() 214 | let t2 = v.normalized() 215 | let cross = t1.cross(t2) 216 | let dot = max(-1, min(1, t1.dot(t2))) 217 | 218 | return atan2(cross, dot) 219 | } 220 | 221 | func dot(_ v: Vector) -> Scalar { 222 | return x * v.x + y * v.y 223 | } 224 | 225 | func cross(_ v: Vector) -> Scalar { 226 | return x * v.y - y * v.x 227 | } 228 | 229 | static func / (lhs: Vector, rhs: Vector) -> Vector { 230 | return Vector(lhs.x / rhs.x, lhs.y / rhs.y) 231 | } 232 | 233 | static func / (lhs: Vector, rhs: Scalar) -> Vector { 234 | return Vector(lhs.x / rhs, lhs.y / rhs) 235 | } 236 | 237 | static prefix func - (v: Vector) -> Vector { 238 | return Vector(-v.x, -v.y) 239 | } 240 | 241 | static func + (lhs: Vector, rhs: Vector) -> Vector { 242 | return Vector(lhs.x + rhs.x, lhs.y + rhs.y) 243 | } 244 | 245 | static func - (lhs: Vector, rhs: Vector) -> Vector { 246 | return Vector(lhs.x - rhs.x, lhs.y - rhs.y) 247 | } 248 | } 249 | 250 | extension CGPoint: Hashable { 251 | public func hash(into hasher: inout Hasher) { 252 | hasher.combine(x) 253 | hasher.combine(y) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/TransitionsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TransitionArrowView: View { 4 | let transition: AutomatonTransition 5 | 6 | var body: some View { 7 | switch transition.type { 8 | case let .regular(startPoint, tipPoint, flexPoint): 9 | Path { path in 10 | path.arrow(startPoint: startPoint, tipPoint: tipPoint, flexPoint: flexPoint) 11 | } 12 | .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round)) 13 | .foregroundColor(.white) 14 | case let .cycle(point, center: center, radians: _): 15 | Path { path in 16 | path.cycle(point: point, center: center) 17 | } 18 | .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round)) 19 | .foregroundColor(.white) 20 | EmptyView() 21 | } 22 | } 23 | } 24 | 25 | struct TransitionModifierView: View { 26 | let transition: AutomatonTransition 27 | let scribblePosition: CGPoint 28 | let toggleEpsilonInclusion: ((AutomatonState.ID) -> Void) 29 | let transitionSymbolRemoved: ((AutomatonTransition.ID, String) -> Void) 30 | let transitionSymbolChanged: ((AutomatonTransition.ID, String) -> Void) 31 | let transitionSymbolAdded: ((AutomatonTransition.ID) -> Void) 32 | 33 | var body: some View { 34 | VStack(alignment: .center) { 35 | if transition.symbols.isEmpty { 36 | HStack { 37 | Text("a") 38 | .foregroundColor(.white) 39 | Image(systemName: "xmark") 40 | .foregroundColor(.white) 41 | } 42 | .padding(.all, 5) 43 | .opacity(0) 44 | } else { 45 | FlexibleView( 46 | data: transition.symbols, 47 | spacing: 3, 48 | alignment: .leading, 49 | content: { symbol in 50 | Button( 51 | action: { transitionSymbolRemoved(transition.id, symbol) } 52 | ) { 53 | HStack { 54 | Text(symbol) 55 | .foregroundColor(.white) 56 | Image(systemName: "xmark") 57 | .foregroundColor(.white) 58 | } 59 | .padding(.all, 5) 60 | .background(Color(UIColor.darkGray)) 61 | .cornerRadius(10) 62 | } 63 | } 64 | ) 65 | .frame(width: 200) 66 | } 67 | HStack { 68 | Button( 69 | action: { 70 | toggleEpsilonInclusion(transition.id) 71 | } 72 | ) { 73 | Text("ε") 74 | .foregroundColor(transition.includesEpsilon ? Color.white : Color.blue) 75 | .padding(7) 76 | .background(transition.includesEpsilon ? Color.blue : Color.clear) 77 | .clipShape(Circle()) 78 | .overlay( 79 | Circle() 80 | .stroke(Color.blue, lineWidth: 2) 81 | ) 82 | } 83 | TextField( 84 | "", 85 | text: Binding( 86 | get: { transition.currentSymbol }, 87 | set: { transitionSymbolChanged(transition.id, $0) } 88 | ) 89 | ) 90 | .textInputAutocapitalization(TextInputAutocapitalization.never) 91 | .autocorrectionDisabled(true) 92 | .multilineTextAlignment(.center) 93 | .padding(2) 94 | .overlay( 95 | RoundedRectangle(cornerRadius: 5) 96 | .stroke(Color.white, lineWidth: 2) 97 | ) 98 | .frame(width: 50, height: 30) 99 | Button( 100 | action: { transitionSymbolAdded(transition.id) } 101 | ) { 102 | Image(systemName: "plus.circle.fill") 103 | } 104 | } 105 | } 106 | .position(scribblePosition) 107 | } 108 | } 109 | 110 | struct TransitionDragControl: View { 111 | let transition: AutomatonTransition 112 | let flexPoint: CGPoint 113 | let currentFlexPoint: CGPoint 114 | let transitionDragged: ((AutomatonTransition.ID, CGPoint) -> Void) 115 | let transitionFinishedDragging: ((AutomatonTransition.ID, CGPoint) -> Void) 116 | 117 | var body: some View { 118 | ZStack { 119 | Circle() 120 | .fill(Color.blue) 121 | .frame(width: 30) 122 | Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") 123 | .frame(width: 25) 124 | } 125 | .padding(15) 126 | .background(Color.black.opacity(0.00001)) 127 | .position(currentFlexPoint) 128 | .offset(x: flexPoint.x - currentFlexPoint.x, y: flexPoint.y - currentFlexPoint.y) 129 | .gesture( 130 | DragGesture() 131 | .onChanged { value in 132 | transitionDragged( 133 | transition.id, 134 | CGPoint( 135 | x: currentFlexPoint.x + value.translation.width, 136 | y: currentFlexPoint.y + value.translation.height 137 | ) 138 | ) 139 | } 140 | .onEnded { value in 141 | transitionFinishedDragging( 142 | transition.id, 143 | CGPoint( 144 | x: currentFlexPoint.x + value.translation.width, 145 | y: currentFlexPoint.y + value.translation.height 146 | ) 147 | ) 148 | } 149 | ) 150 | } 151 | } 152 | 153 | struct TransitionEraseButton: View { 154 | let transitionRemoved: () -> Void 155 | 156 | var body: some View { 157 | Button(action: { transitionRemoved() }) { 158 | ZStack { 159 | Circle() 160 | .fill(.red) 161 | .frame(width: 30) 162 | Image(systemName: "minus") 163 | .foregroundColor(.white) 164 | .frame(width: 25) 165 | } 166 | } 167 | .padding(15) 168 | .background(Color.black.opacity(0.00001)) 169 | } 170 | } 171 | 172 | /// View that holds all the transitions. 173 | struct TransitionsView: View { 174 | let transitions: [AutomatonTransition] 175 | let toggleEpsilonInclusion: ((AutomatonState.ID) -> Void) 176 | let transitionSymbolRemoved: ((AutomatonTransition.ID, String) -> Void) 177 | let transitionSymbolChanged: ((AutomatonTransition.ID, String) -> Void) 178 | let transitionSymbolAdded: ((AutomatonTransition.ID) -> Void) 179 | let transitionRemoved: ((AutomatonTransition.ID) -> Void) 180 | let transitionDragged: ((AutomatonTransition.ID, CGPoint) -> Void) 181 | let transitionFinishedDragging: ((AutomatonTransition.ID, CGPoint) -> Void) 182 | let mode: EditorFeature.Mode 183 | 184 | var body: some View { 185 | ForEach(transitions) { transition in 186 | ZStack { 187 | TransitionArrowView(transition: transition) 188 | 189 | if let scribblePosition = transition.scribblePosition, mode != .erasing { 190 | TransitionModifierView( 191 | transition: transition, 192 | scribblePosition: scribblePosition, 193 | toggleEpsilonInclusion: toggleEpsilonInclusion, 194 | transitionSymbolRemoved: transitionSymbolRemoved, 195 | transitionSymbolChanged: transitionSymbolChanged, 196 | transitionSymbolAdded: transitionSymbolAdded 197 | ) 198 | } 199 | 200 | switch transition.type { 201 | case let .cycle(point, center: center, radians: _): 202 | if mode == .erasing { 203 | TransitionEraseButton { transitionRemoved(transition.id) } 204 | .position( 205 | Vector(point, center) 206 | .rotated(by: .pi / 11) 207 | .point(distance: -55, other: point)) 208 | } 209 | case let .regular(startPoint: _, tipPoint: _, flexPoint: flexPoint): 210 | switch mode { 211 | case .editing, .addingTransition, .addingCycle, .addingFinalState, .addingInitialState: 212 | TransitionDragControl( 213 | transition: transition, 214 | flexPoint: flexPoint, 215 | currentFlexPoint: transition.currentFlexPoint ?? flexPoint, 216 | transitionDragged: transitionDragged, 217 | transitionFinishedDragging: transitionFinishedDragging 218 | ) 219 | case .erasing: 220 | TransitionEraseButton { transitionRemoved(transition.id) } 221 | .position(flexPoint) 222 | } 223 | } 224 | } 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /AutomataEditorTests/EditorTests/EditorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import ComposableArchitecture 3 | @testable import AutomataEditor 4 | 5 | @MainActor 6 | final class EditorTests: XCTestCase { 7 | let scheduler = DispatchQueue.test 8 | var stubShapeType: AutomatonShapeType! 9 | var stubID: String! 10 | var stubCenter: CGPoint! 11 | var stubRadius: CGFloat! 12 | var automatonURL = URL(string: "file://some-file")! 13 | 14 | override func setUp() { 15 | super.setUp() 16 | 17 | stubShapeType = .circle 18 | stubID = "1" 19 | stubCenter = .zero 20 | stubRadius = 1 21 | } 22 | 23 | override func tearDown() { 24 | stubShapeType = nil 25 | stubID = nil 26 | stubCenter = nil 27 | stubRadius = nil 28 | 29 | super.tearDown() 30 | } 31 | 32 | func testManualTransitionCreation() async { 33 | var currentStrokes: [Stroke] = [] 34 | let store = TestStore( 35 | initialState: EditorFeature.State(automatonURL: automatonURL), 36 | reducer: EditorFeature() 37 | ) 38 | self.stubRadius = 5 39 | store.dependencies.shapeService = .mock( 40 | center: { $0.first ?? .zero }, 41 | radius: { _, _ in self.stubRadius } 42 | ) 43 | store.dependencies.idFactory = .mock { self.stubID } 44 | store.dependencies.automataClassifierService = .successfulShape { self.stubShapeType } 45 | await store.send(.startAddingTransition) { 46 | $0.mode = .addingTransition 47 | } 48 | self.stubID = "A" 49 | await createState( 50 | store: store, 51 | id: "A", 52 | center: CGPoint(x: 5, y: 5), 53 | radius: 5, 54 | currentStrokes: ¤tStrokes 55 | ) 56 | self.stubID = "B" 57 | 58 | await createState( 59 | store: store, 60 | id: "B", 61 | center: CGPoint(x: 20, y: 5), 62 | radius: 5, 63 | currentStrokes: ¤tStrokes 64 | ) 65 | 66 | await store.send(.selectedStateForTransition("A")) { 67 | $0.currentlySelectedStateForTransition = "A" 68 | } 69 | await store.send(.selectedStateForTransition("A")) { 70 | $0.currentlySelectedStateForTransition = nil 71 | } 72 | await store.send(.selectedStateForTransition("A")) { 73 | $0.currentlySelectedStateForTransition = "A" 74 | } 75 | self.stubID = "T" 76 | await store.send(.selectedStateForTransition("B")) { 77 | $0.transitionsDict["T"] = AutomatonTransition( 78 | id: "T", 79 | startState: "A", 80 | endState: "B", 81 | type: .regular( 82 | startPoint: CGPoint(x: 10, y: 5), 83 | tipPoint: CGPoint(x: 15, y: 5), 84 | flexPoint: CGPoint(x: 12.5, y: 5) 85 | ) 86 | ) 87 | $0.currentlySelectedStateForTransition = nil 88 | $0.mode = .editing 89 | } 90 | } 91 | 92 | func testTransitionSymbolIsTrimmedAndUppercased() async { 93 | var currentStrokes: [Stroke] = [] 94 | let store = TestStore( 95 | initialState: EditorFeature.State(automatonURL: automatonURL), 96 | reducer: EditorFeature() 97 | ) 98 | store.dependencies.automataClassifierService = .successfulShape { .arrow } 99 | store.dependencies.automataLibraryService = .successful() 100 | store.dependencies.shapeService = .mock( 101 | center: { $0.first ?? .zero }, 102 | radius: { _, _ in 1 } 103 | ) 104 | store.dependencies.idFactory = .mock { self.stubID } 105 | 106 | await createTransition( 107 | store: store, 108 | startPoint: .zero, 109 | tipPoint: CGPoint(x: 1, y: 0), 110 | transitionID: stubID, 111 | currentStrokes: ¤tStrokes 112 | ) 113 | await store.send(.transitionSymbolChanged(stubID, "a \n")) { [self] in 114 | $0.transitionsDict[stubID]?.currentSymbol = "A" 115 | } 116 | } 117 | 118 | func testSimulateWithoutInitialState() async { 119 | var currentStrokes: [Stroke] = [] 120 | let store = TestStore( 121 | initialState: EditorFeature.State(automatonURL: automatonURL), 122 | reducer: EditorFeature() 123 | ) 124 | store.dependencies.automataClassifierService = .successfulShape { self.stubShapeType } 125 | store.dependencies.automataLibraryService = .successful() 126 | store.dependencies.shapeService = .mock( 127 | center: { $0.first ?? .zero }, 128 | radius: { _, _ in self.stubRadius } 129 | ) 130 | store.dependencies.idFactory = .mock { self.stubID } 131 | store.dependencies.mainQueue = scheduler.eraseToAnyScheduler() 132 | await createState( 133 | store: store, 134 | id: stubID, 135 | center: stubCenter, 136 | radius: stubRadius, 137 | currentStrokes: ¤tStrokes 138 | ) 139 | 140 | await store.send(.stateSymbolChanged("1", "A")) { 141 | $0.automatonStatesDict["1"]?.name = "A" 142 | } 143 | 144 | await createEndStateStroke( 145 | store: store, 146 | stateID: "1", 147 | controlPoints: [.zero], 148 | currentStrokes: ¤tStrokes 149 | ) 150 | 151 | await store.send(.inputChanged("A")) { 152 | $0.input = "A" 153 | } 154 | await store.send(.simulateInput) { 155 | $0.outputString = "❌ No initial state" 156 | } 157 | } 158 | 159 | func testSimulateWithMultipleInitialStates() async { 160 | var currentStrokes: [Stroke] = [] 161 | let store = TestStore( 162 | initialState: EditorFeature.State(automatonURL: automatonURL), 163 | reducer: EditorFeature() 164 | ) 165 | store.dependencies.automataClassifierService = .successfulShape { self.stubShapeType } 166 | store.dependencies.automataLibraryService = .successful() 167 | store.dependencies.shapeService = .mock( 168 | center: { $0.first ?? .zero }, 169 | radius: { _, _ in self.stubRadius } 170 | ) 171 | store.dependencies.idFactory = .mock { self.stubID } 172 | 173 | await createState( 174 | store: store, 175 | id: stubID, 176 | center: stubCenter, 177 | radius: stubRadius, 178 | currentStrokes: ¤tStrokes 179 | ) 180 | 181 | await store.send(.stateSymbolChanged("1", "A")) { 182 | $0.automatonStatesDict["1"]?.name = "A" 183 | } 184 | 185 | await createEndStateStroke( 186 | store: store, 187 | stateID: "1", 188 | controlPoints: [.zero], 189 | currentStrokes: ¤tStrokes 190 | ) 191 | 192 | stubID = "2" 193 | await createState( 194 | store: store, 195 | id: stubID, 196 | center: CGPoint(x: 10, y: 0), 197 | radius: stubRadius, 198 | currentStrokes: ¤tStrokes 199 | ) 200 | await store.send(.stateSymbolChanged("2", "B")) { 201 | $0.automatonStatesDict["2"]?.name = "B" 202 | } 203 | 204 | stubShapeType = .arrow 205 | 206 | stubID = "1" 207 | await createTransition( 208 | store: store, 209 | startPoint: CGPoint(x: -2, y: 0), 210 | tipPoint: CGPoint(x: 0, y: 0), 211 | transitionID: stubID, 212 | endStateID: "1", 213 | currentStrokes: ¤tStrokes 214 | ) 215 | 216 | stubID = "2" 217 | await createTransition( 218 | store: store, 219 | startPoint: CGPoint(x: 8, y: 0), 220 | tipPoint: CGPoint(x: 10, y: 0), 221 | transitionID: stubID, 222 | endStateID: "2", 223 | currentStrokes: ¤tStrokes 224 | ) 225 | 226 | await store.send(.simulateInput) { 227 | $0.outputString = "❌ Multiple initial states" 228 | } 229 | } 230 | 231 | func testSimpleAutomatonIsDrawnAndSimulated() async { 232 | var currentStrokes: [Stroke] = [] 233 | let store = TestStore( 234 | initialState: EditorFeature.State(automatonURL: automatonURL), 235 | reducer: EditorFeature() 236 | ) 237 | store.dependencies.automataClassifierService = .successfulShape { self.stubShapeType } 238 | store.dependencies.automataLibraryService = .successful() 239 | store.dependencies.shapeService = .mock( 240 | center: { $0.first ?? .zero }, 241 | radius: { _, _ in self.stubRadius } 242 | ) 243 | store.dependencies.idFactory = .mock { self.stubID } 244 | await createState( 245 | store: store, 246 | id: stubID, 247 | center: stubCenter, 248 | radius: stubRadius, 249 | currentStrokes: ¤tStrokes 250 | ) 251 | 252 | await store.send(.stateSymbolChanged("1", "A")) { 253 | $0.automatonStatesDict["1"]?.name = "A" 254 | } 255 | 256 | stubShapeType = .arrow 257 | 258 | stubID = "2" 259 | await createTransition( 260 | store: store, 261 | startPoint: .zero, 262 | tipPoint: CGPoint(x: 2, y: 0), 263 | transitionID: stubID, 264 | startStateID: "1", 265 | currentStrokes: ¤tStrokes 266 | ) 267 | 268 | await store.send(.transitionSymbolChanged("2", "A")) { 269 | $0.transitionsDict["2"]?.currentSymbol = "A" 270 | } 271 | 272 | stubShapeType = .circle 273 | stubID = "3" 274 | 275 | await createState( 276 | store: store, 277 | id: "3", 278 | center: CGPoint(x: 3, y: 0), 279 | radius: 1, 280 | transitionEndID: "2", 281 | currentStrokes: ¤tStrokes 282 | ) 283 | await store.send(.stateSymbolChanged("3", "3")) { 284 | $0.automatonStatesDict["3"]?.name = "3" 285 | } 286 | 287 | currentStrokes.append( 288 | Stroke( 289 | controlPoints: [CGPoint(x: 3, y: 0)] 290 | ) 291 | ) 292 | await store.send( 293 | .strokesChanged( 294 | currentStrokes 295 | ) 296 | ) 297 | 298 | await store.receive( 299 | .automataShapeClassified( 300 | .success( 301 | .state( 302 | Stroke( 303 | controlPoints: [CGPoint(x: 3, y: 0)] 304 | ) 305 | ) 306 | ) 307 | ) 308 | ) { 309 | $0.automatonStatesDict["3"]?.isFinalState = true 310 | } 311 | 312 | stubShapeType = .arrow 313 | stubID = "4" 314 | await createTransition( 315 | store: store, 316 | startPoint: CGPoint(x: -2, y: 0), 317 | tipPoint: .zero, 318 | transitionID: stubID, 319 | endStateID: "1", 320 | currentStrokes: ¤tStrokes 321 | ) 322 | 323 | await store.send(.inputChanged("A")) { 324 | $0.input = "A" 325 | } 326 | await store.send(.simulateInput) 327 | await store.receive(.simulateInputResult(.success(Empty()))) { 328 | $0.outputString = "✅" 329 | } 330 | } 331 | 332 | private func createEndStateStroke( 333 | store: TestStore, 334 | stateID: AutomatonState.ID, 335 | controlPoints: [CGPoint], 336 | currentStrokes: inout [Stroke] 337 | ) async { 338 | currentStrokes.append( 339 | Stroke( 340 | controlPoints: controlPoints 341 | ) 342 | ) 343 | await store.send( 344 | .strokesChanged( 345 | currentStrokes 346 | ) 347 | ) 348 | 349 | await store.receive( 350 | .automataShapeClassified( 351 | .success( 352 | .state( 353 | Stroke( 354 | controlPoints: controlPoints 355 | ) 356 | ) 357 | ) 358 | ) 359 | ) { 360 | $0.automatonStatesDict[stateID]?.isFinalState = true 361 | } 362 | } 363 | 364 | private func createState( 365 | store: TestStore, 366 | id: String, 367 | center: CGPoint, 368 | radius: CGFloat, 369 | transitionEndID: AutomatonTransition.ID? = nil, 370 | currentStrokes: inout [Stroke] 371 | ) async { 372 | await store.send( 373 | .strokesChanged( 374 | currentStrokes + [ 375 | Stroke(controlPoints: [center]) 376 | ] 377 | ) 378 | ) 379 | await scheduler.advance() 380 | 381 | await store.receive( 382 | .automataShapeClassified( 383 | .success( 384 | .state( 385 | Stroke( 386 | controlPoints: [center] 387 | ) 388 | ) 389 | ) 390 | ) 391 | ) { 392 | $0.automatonStatesDict[id] = AutomatonState( 393 | id: id, 394 | center: center, 395 | radius: radius 396 | ) 397 | 398 | if let transitionEndID = transitionEndID { 399 | $0.transitionsDict[transitionEndID]?.endState = id 400 | } 401 | } 402 | 403 | currentStrokes.append( 404 | Stroke( 405 | controlPoints: [center] 406 | ) 407 | ) 408 | } 409 | 410 | 411 | private func createTransition( 412 | store: TestStore, 413 | startPoint: CGPoint, 414 | tipPoint: CGPoint, 415 | transitionID: String, 416 | startStateID: AutomatonState.ID? = nil, 417 | endStateID: AutomatonState.ID? = nil, 418 | currentStrokes: inout [Stroke] 419 | ) async { 420 | let stroke = Stroke( 421 | controlPoints: [ 422 | startPoint, 423 | tipPoint, 424 | ] 425 | ) 426 | await store.send( 427 | .strokesChanged( 428 | currentStrokes + [ 429 | stroke, 430 | ] 431 | ) 432 | ) 433 | 434 | await scheduler.advance() 435 | 436 | await store.receive( 437 | .automataShapeClassified( 438 | .success( 439 | .transition(stroke) 440 | ) 441 | ) 442 | ) { 443 | $0.transitionsDict[transitionID] = AutomatonTransition( 444 | id: transitionID, 445 | startState: startStateID, 446 | endState: endStateID, 447 | type: .regular( 448 | startPoint: startPoint, 449 | tipPoint: tipPoint, 450 | flexPoint: CGPoint( 451 | x: (startPoint.x + tipPoint.x) / 2, 452 | y: (startPoint.y + tipPoint.y) / 2 453 | ) 454 | ), 455 | currentFlexPoint: CGPoint( 456 | x: (startPoint.x + tipPoint.x) / 2, 457 | y: (startPoint.y + tipPoint.y) / 2 458 | ) 459 | ) 460 | } 461 | 462 | currentStrokes.append( 463 | Stroke( 464 | controlPoints: [startPoint, tipPoint] 465 | ) 466 | ) 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /AutomataEditor/Editor/EditorStore.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import CoreGraphics 3 | import PencilKit 4 | import CoreML 5 | import SwiftAutomataLibrary 6 | import SwiftUI 7 | import UniformTypeIdentifiers 8 | 9 | extension UTType { 10 | static let automatonDocument = UTType(exportedAs: "marekfort.AutomataEditor.automaton") 11 | } 12 | 13 | // MARK: - Environment 14 | 15 | struct EditorEnvironment { 16 | let automataClassifierService: AutomataClassifierService 17 | let automataLibraryService: AutomataLibraryService 18 | let shapeService: ShapeService 19 | let idFactory: IDFactory 20 | let mainQueue: AnySchedulerOf 21 | } 22 | 23 | extension EditorFeature.State { 24 | var automatonStates: [AutomatonState] { 25 | automatonStatesDict.map(\.value) 26 | } 27 | var transitions: [AutomatonTransition] { 28 | transitionsDict.map(\.value) 29 | } 30 | 31 | fileprivate var transitionsWithoutEndState: [AutomatonTransition] { 32 | transitions.filter { $0.endState == nil } 33 | } 34 | 35 | fileprivate var transitionsWithoutStartState: [AutomatonTransition] { 36 | transitions.filter { $0.startState == nil } 37 | } 38 | 39 | var initialStates: [AutomatonState] { 40 | transitionsWithoutStartState 41 | .compactMap(\.endState) 42 | .compactMap { stateID in 43 | automatonStates.first(where: { $0.id == stateID }) 44 | } 45 | } 46 | 47 | fileprivate var finalStates: [AutomatonState] { 48 | automatonStates.filter(\.isFinalState) 49 | } 50 | } 51 | 52 | // MARK: - Reducer 53 | 54 | struct EditorFeature: ReducerProtocol { 55 | enum Mode: Equatable { 56 | case editing, addingTransition, erasing, addingCycle, addingFinalState, addingInitialState 57 | } 58 | 59 | struct State: Equatable { 60 | init( 61 | automatonURL: URL, 62 | id: UUID = UUID(), 63 | tool: Tool = .pen, 64 | isEraserSelected: Bool = false, 65 | isPenSelected: Bool = true, 66 | automatonOutput: AutomatonOutput = .success, 67 | input: String = "", 68 | automatonStatesDict: [AutomatonState.ID : AutomatonState] = [:], 69 | transitionsDict: [AutomatonTransition.ID : AutomatonTransition] = [:], 70 | shouldDeleteLastStroke: Bool = false 71 | ) { 72 | self.automatonURL = automatonURL 73 | self.id = id 74 | self.tool = tool 75 | self.isEraserSelected = isEraserSelected 76 | self.isPenSelected = isPenSelected 77 | self.automatonOutput = automatonOutput 78 | self.input = input 79 | self.automatonStatesDict = automatonStatesDict 80 | self.transitionsDict = transitionsDict 81 | self.shouldDeleteLastStroke = shouldDeleteLastStroke 82 | } 83 | 84 | let automatonURL: URL 85 | let id: UUID 86 | var tool: Tool = .pen 87 | var isEraserSelected: Bool = false 88 | var isPenSelected: Bool = true 89 | var input: String = "" 90 | var automatonStatesDict: [AutomatonState.ID: AutomatonState] = [:] 91 | var transitionsDict: [AutomatonTransition.ID: AutomatonTransition] = [:] 92 | var shouldDeleteLastStroke = false 93 | var viewSize: CGSize = .zero 94 | var mode: Mode = .editing 95 | var currentlySelectedStateForTransition: AutomatonState.ID? 96 | var currentVisibleScrollViewRect: CGRect? 97 | var isClearAlertPresented = false 98 | var automatonOutput: AutomatonOutput = .success 99 | var isAutomatonOutputVisible = false 100 | } 101 | 102 | enum AutomatonOutput: Equatable { 103 | case success 104 | case failure(String?) 105 | } 106 | 107 | 108 | enum Action: Equatable { 109 | case clear 110 | case selectedEraser 111 | case selectedPen 112 | case removeLastInputSymbol 113 | case inputChanged(String) 114 | case simulateInput 115 | case simulateInputResult(Result) 116 | case stateSymbolChanged(AutomatonState.ID, String) 117 | case stateDragPointFinishedDragging(AutomatonState.ID, CGPoint) 118 | case stateDragPointChanged(AutomatonState.ID, CGPoint) 119 | case toggleEpsilonInclusion(AutomatonTransition.ID) 120 | case transitionFlexPointFinishedDragging(AutomatonTransition.ID, CGPoint) 121 | case transitionFlexPointChanged(AutomatonTransition.ID, CGPoint) 122 | case transitionSymbolChanged(AutomatonTransition.ID, String) 123 | case transitionSymbolAdded(AutomatonTransition.ID) 124 | case transitionSymbolRemoved(AutomatonTransition.ID, String) 125 | case strokesChanged([Stroke]) 126 | case automatonStateRemoved(AutomatonState.ID) 127 | case transitionRemoved(AutomatonTransition.ID) 128 | case automataShapeClassified(Result) 129 | case stateUpdated(State) 130 | case addNewState 131 | case viewSizeChanged(CGSize) 132 | case startAddingTransition 133 | case startAddingCycle 134 | case stopAddingCycle 135 | case stopAddingTransition 136 | case startAddingFinalState 137 | case stopAddingFinalState 138 | case startAddingInitialState 139 | case stopAddingInitialState 140 | case selectedInitialState(AutomatonState.ID) 141 | case selectedStateForTransition(AutomatonState.ID) 142 | case selectedStateForCycle(AutomatonState.ID) 143 | case selectedFinalState(AutomatonState.ID) 144 | case currentVisibleScrollViewRectChanged(CGRect) 145 | case clearButtonPressed 146 | case clearAlertDismissed 147 | case dismissToast 148 | } 149 | 150 | @Dependency(\.idFactory) var idFactory 151 | @Dependency(\.automataLibraryService) var automataLibraryService 152 | @Dependency(\.automataClassifierService) var automataClassifierService 153 | @Dependency(\.shapeService) var shapeService 154 | @Dependency(\.continuousClock) var clock 155 | private enum TimerID {} 156 | 157 | struct ClosestStateResult { 158 | let state: AutomatonState 159 | let point: CGPoint 160 | let distance: CGFloat 161 | } 162 | 163 | func reduce(into state: inout State, action: Action) -> EffectTask { 164 | /// - Returns: Closest automaton state, if exists, from a given `point` 165 | func closestState(from point: CGPoint) -> ClosestStateResult? { 166 | let result: (AutomatonState?, CGPoint, CGFloat) = state.automatonStates.reduce((nil, .zero, CGFloat.infinity)) { acc, currentState in 167 | let closestPoint: CGPoint = stroke(for: currentState).controlPoints.closestPoint(from: point) 168 | let currentDistance = closestPoint.distance(from: point) 169 | return currentDistance < acc.2 ? (currentState, closestPoint, currentDistance) : acc 170 | } 171 | 172 | if let state = result.0 { 173 | return ClosestStateResult( 174 | state: state, 175 | point: result.1, 176 | distance: result.2 177 | ) 178 | } else { 179 | return nil 180 | } 181 | } 182 | 183 | /// - Returns: Stroke for `automatonState` using `shapeService` 184 | func stroke(for automatonState: AutomatonState) -> Stroke { 185 | return Stroke( 186 | controlPoints: shapeService.circle( 187 | automatonState.center, 188 | automatonState.radius 189 | ) 190 | ) 191 | } 192 | 193 | /// - Returns: State that encapsulates `controlPoints` 194 | func enclosingState(for controlPoints: [CGPoint]) -> AutomatonState? { 195 | guard 196 | let minX = controlPoints.min(by: { $0.x < $1.x })?.x, 197 | let maxX = controlPoints.max(by: { $0.x < $1.x })?.x, 198 | let minY = controlPoints.min(by: { $0.y < $1.y })?.y, 199 | let maxY = controlPoints.max(by: { $0.y < $1.y })?.y 200 | else { return nil } 201 | return state.automatonStates.first( 202 | where: { 203 | CGRect( 204 | x: minX, 205 | y: minY, 206 | width: maxX - minX, 207 | height: maxY - minY 208 | ) 209 | .contains($0.center) || CGPoint(x: minX, y: minY) == $0.center 210 | } 211 | ) 212 | } 213 | 214 | /// - Returns: Closest transition for array of `controlPoints` that does not have an end state. Nil if none pases a threshold. 215 | func closestTransitionWithoutEndState( 216 | for controlPoints: [CGPoint] 217 | ) -> AutomatonTransition? { 218 | state.transitionsWithoutEndState.first( 219 | where: { 220 | switch $0.type { 221 | case .cycle: 222 | return false 223 | case let .regular( 224 | startPoint: _, 225 | tipPoint: tipPoint, 226 | flexPoint: _ 227 | ): 228 | return sqrt(controlPoints.closestPoint(from: tipPoint).distance(from: tipPoint)) < 40 229 | } 230 | } 231 | ) 232 | } 233 | 234 | /// - Returns: Closest transition for array of `controlPoints` that does not have a start state. Nil if none pases a threshold. 235 | func closestTransitionWithoutStartState( 236 | for controlPoints: [CGPoint] 237 | ) -> AutomatonTransition? { 238 | state.transitionsWithoutStartState 239 | .first( 240 | where: { 241 | switch $0.type { 242 | case .cycle: 243 | return false 244 | case let .regular( 245 | startPoint: startPoint, 246 | tipPoint: _, 247 | flexPoint: _ 248 | ): 249 | return sqrt(controlPoints.closestPoint(from: startPoint).distance(from: startPoint)) < 40 250 | } 251 | } 252 | ) 253 | } 254 | 255 | func updateOutcomingTransitionsAfterStateDragged(_ automatonStateID: AutomatonState.ID) { 256 | state.transitions 257 | .filter { $0.endState == automatonStateID && $0.endState != $0.startState } 258 | .forEach { transition in 259 | guard 260 | let flexPoint = transition.flexPoint, 261 | let endStateID = transition.endState, 262 | let endState = state.automatonStatesDict[endStateID] 263 | else { return } 264 | let vector = Vector(endState.center, flexPoint) 265 | // Closest intersection point between flex point and end state 266 | state.transitionsDict[transition.id]?.tipPoint = vector.point(distance: endState.radius, other: endState.center) 267 | } 268 | } 269 | 270 | func updateIncomingTransitionsAfterStateDragged(_ automatonStateID: AutomatonState.ID) { 271 | state.transitions 272 | .filter { $0.startState == automatonStateID && $0.endState != $0.startState } 273 | .forEach { transition in 274 | guard 275 | let flexPoint = transition.flexPoint, 276 | let startStateID = transition.startState, 277 | let startState = state.automatonStatesDict[startStateID] 278 | else { return } 279 | let vector = Vector(startState.center, flexPoint) 280 | // Closest intersection point between flex point and start state 281 | state.transitionsDict[transition.id]?.startPoint = vector.point(distance: startState.radius, other: startState.center) 282 | } 283 | } 284 | 285 | func updateCyclesAfterStateDragged(_ automatonStateID: AutomatonState.ID) { 286 | state.transitions 287 | .forEach { transition in 288 | switch transition.type { 289 | case let .cycle(_, center: _, radians: radians): 290 | guard 291 | let endStateID = transition.endState, 292 | let endState = state.automatonStatesDict[endStateID] 293 | else { return } 294 | let vector = Vector( 295 | endState.center, 296 | CGPoint( 297 | x: endState.center.x, 298 | y: endState.center.y + endState.radius 299 | ) 300 | ) 301 | // We use saved rotation and apply to the new center of a dragged state. 302 | .rotated(by: radians) 303 | state.transitionsDict[transition.id]?.type = .cycle( 304 | vector.point(distance: endState.radius, other: endState.center), 305 | center: endState.center, 306 | radians: radians 307 | ) 308 | case .regular: 309 | break 310 | } 311 | } 312 | } 313 | 314 | /// Transitions must be updated after a state is dragged if they were connected to it. 315 | func updateTransitionsAfterStateDragged(_ automatonStateID: AutomatonState.ID) { 316 | updateOutcomingTransitionsAfterStateDragged(automatonStateID) 317 | updateIncomingTransitionsAfterStateDragged(automatonStateID) 318 | updateCyclesAfterStateDragged(automatonStateID) 319 | } 320 | 321 | func deleteStroke(from strokes: [Stroke]) { 322 | let centerPoints = strokes 323 | .map(\.controlPoints) 324 | .map(shapeService.center) 325 | // Find closest transition and delete it if it passes a given threshold 326 | if let transition = state.transitions.first( 327 | where: { transition in 328 | !strokes.contains( 329 | where: { stroke in 330 | switch transition.type { 331 | case let .cycle(point, center: _, radians: _): 332 | return point.distance(from: stroke.controlPoints[0]) <= 0.1 333 | case let .regular(startPoint, _, _): 334 | return startPoint.distance(from: stroke.controlPoints[0]) <= 0.1 335 | } 336 | } 337 | ) 338 | } 339 | ) { 340 | state.transitionsDict.removeValue(forKey: transition.id) 341 | // Find closest automaton state to delete that passes a given threshold 342 | } else if let automatonState = state.automatonStates.first( 343 | where: { state in 344 | !centerPoints.contains( 345 | where: { 346 | sqrt(state.center.distance(from: $0)) < 20 347 | } 348 | ) 349 | } 350 | ) { 351 | state.automatonStatesDict.removeValue(forKey: automatonState.id) 352 | state.transitions.forEach { transition in 353 | var transition = transition 354 | switch transition.type { 355 | case .cycle: 356 | if let transitionStartState = transition.startState, 357 | transitionStartState == automatonState.id { 358 | state.transitionsDict.removeValue(forKey: transition.id) 359 | } 360 | case .regular: 361 | if transition.startState == automatonState.id { 362 | transition.startState = nil 363 | } 364 | if transition.endState == automatonState.id { 365 | transition.endState = nil 366 | } 367 | state.transitionsDict[transition.id] = transition 368 | } 369 | } 370 | } 371 | } 372 | 373 | func updateDraggedTransition(_ transition: AutomatonTransition, flexPoint: CGPoint) { 374 | var transition = transition 375 | transition.flexPoint = flexPoint 376 | if 377 | transition.isInitialTransition, 378 | let tipPoint = transition.tipPoint { 379 | let vector = Vector(tipPoint, flexPoint) 380 | // Recompute start point for initial transitions. 381 | // Otherwise transition's start point can only be changed with a connected state 382 | transition.startPoint = vector.point(distance: sqrt(vector.lengthSquared), other: flexPoint) 383 | } 384 | state.transitionsDict[transition.id] = transition 385 | } 386 | 387 | func showAutomatonOutput(_ automatonOutput: AutomatonOutput) -> EffectTask { 388 | state.automatonOutput = automatonOutput 389 | state.isAutomatonOutputVisible = true 390 | 391 | return .run { send in 392 | try await clock.sleep(for: .seconds(5)) 393 | return await send(.dismissToast, animation: .spring()) 394 | } 395 | .cancellable(id: TimerID.self, cancelInFlight: true) 396 | } 397 | 398 | switch action { 399 | case let .currentVisibleScrollViewRectChanged(currentVisibleScrollViewRect): 400 | state.currentVisibleScrollViewRect = currentVisibleScrollViewRect 401 | case let .automatonStateRemoved(automatonStateID): 402 | if state.automatonStatesDict[automatonStateID]?.isFinalState == true { 403 | state.automatonStatesDict[automatonStateID]?.isFinalState = false 404 | return .none 405 | } 406 | state.automatonStatesDict.removeValue(forKey: automatonStateID) 407 | state.transitions.forEach { transition in 408 | var transition = transition 409 | switch transition.type { 410 | case .cycle: 411 | if let transitionStartState = transition.startState, 412 | transitionStartState == automatonStateID { 413 | state.transitionsDict.removeValue(forKey: transition.id) 414 | } 415 | case .regular: 416 | if transition.startState == automatonStateID { 417 | transition.startState = nil 418 | } 419 | if transition.endState == automatonStateID { 420 | transition.endState = nil 421 | } 422 | state.transitionsDict[transition.id] = transition 423 | } 424 | } 425 | case let .transitionRemoved(transitionID): 426 | state.transitionsDict.removeValue(forKey: transitionID) 427 | case .startAddingTransition: 428 | state.mode = .addingTransition 429 | case .startAddingInitialState: 430 | state.mode = .addingInitialState 431 | case .startAddingCycle: 432 | state.mode = .addingCycle 433 | case .stopAddingCycle, .stopAddingTransition, .stopAddingFinalState, .stopAddingInitialState: 434 | state.mode = state.isPenSelected ? .editing : .erasing 435 | state.currentlySelectedStateForTransition = nil 436 | case .startAddingFinalState: 437 | state.mode = .addingFinalState 438 | case let .selectedFinalState(automatonStateID): 439 | state.automatonStatesDict[automatonStateID]?.isFinalState = true 440 | state.mode = .editing 441 | case let .selectedStateForCycle(automatonStateID): 442 | guard let selectedState = state.automatonStatesDict[automatonStateID] else { return .none } 443 | 444 | let transition = AutomatonTransition( 445 | id: idFactory.generateID(), 446 | startState: automatonStateID, 447 | endState: automatonStateID, 448 | type: .cycle( 449 | CGPoint(x: selectedState.center.x, y: selectedState.center.y - selectedState.radius), 450 | center: selectedState.center, 451 | radians: .pi 452 | ) 453 | ) 454 | state.transitionsDict[transition.id] = transition 455 | state.currentlySelectedStateForTransition = nil 456 | state.mode = .editing 457 | case let .selectedInitialState(automatonStateID): 458 | guard let initialState = state.automatonStatesDict[automatonStateID] else { return .none } 459 | let tipPoint = CGPoint( 460 | x: initialState.center.x - initialState.radius, 461 | y: initialState.center.y 462 | ) 463 | let flexPoint = CGPoint( 464 | x: tipPoint.x - 50, 465 | y: tipPoint.y 466 | ) 467 | let startPoint = CGPoint( 468 | x: tipPoint.x - 100, 469 | y: tipPoint.y 470 | ) 471 | 472 | let transition = AutomatonTransition( 473 | id: idFactory.generateID(), 474 | startState: nil, 475 | endState: automatonStateID, 476 | type: .regular( 477 | startPoint: startPoint, 478 | tipPoint: tipPoint, 479 | flexPoint: flexPoint 480 | ), 481 | currentFlexPoint: flexPoint 482 | ) 483 | state.transitionsDict[transition.id] = transition 484 | state.mode = .editing 485 | case let .selectedStateForTransition(automatonStateID): 486 | if automatonStateID == state.currentlySelectedStateForTransition { 487 | state.currentlySelectedStateForTransition = nil 488 | } else if let currentlySelectedStateForTransition = state.currentlySelectedStateForTransition { 489 | guard 490 | let startState = state.automatonStatesDict[currentlySelectedStateForTransition], 491 | let endState = state.automatonStatesDict[automatonStateID] 492 | else { return .none } 493 | let startStateIntersection = Geometry.intersections( 494 | pointA: startState.center, 495 | pointB: endState.center, 496 | circle: Geometry.Circle(center: startState.center, radius: startState.radius) 497 | ) 498 | let endStateIntersection = Geometry.intersections( 499 | pointA: startState.center, 500 | pointB: endState.center, 501 | circle: Geometry.Circle(center: endState.center, radius: endState.radius) 502 | ) 503 | let shortestVector = [ 504 | (startStateIntersection.0, endStateIntersection.0), 505 | (startStateIntersection.0, endStateIntersection.1), 506 | (startStateIntersection.1, endStateIntersection.0), 507 | (startStateIntersection.1, endStateIntersection.1), 508 | ] 509 | .min( 510 | by: { pointsA, pointsB in 511 | return Vector(pointsA.0, pointsA.1).lengthSquared < Vector(pointsB.0, pointsB.1).lengthSquared 512 | } 513 | ) 514 | guard 515 | let shortestVector = shortestVector 516 | else { return .none } 517 | let (startPoint, tipPoint) = shortestVector 518 | let flexPoint = CGPoint( 519 | x: (startState.center.x + endState.center.x) / 2, 520 | y: (startState.center.y + endState.center.y) / 2 521 | ) 522 | let transition = AutomatonTransition( 523 | id: idFactory.generateID(), 524 | startState: startState.id, 525 | endState: endState.id, 526 | type: .regular( 527 | startPoint: startPoint, 528 | tipPoint: tipPoint, 529 | flexPoint: flexPoint 530 | ), 531 | currentFlexPoint: flexPoint 532 | ) 533 | state.transitionsDict[transition.id] = transition 534 | state.currentlySelectedStateForTransition = nil 535 | state.mode = .editing 536 | } else { 537 | state.currentlySelectedStateForTransition = automatonStateID 538 | } 539 | case .addNewState: 540 | let center: CGPoint 541 | 542 | if let currentVisibleScrollViewRect = state.currentVisibleScrollViewRect { 543 | center = CGPoint( 544 | x: currentVisibleScrollViewRect.origin.x + currentVisibleScrollViewRect.width / 2, 545 | y: currentVisibleScrollViewRect.origin.y + currentVisibleScrollViewRect.height * 0.5 546 | ) 547 | } else { 548 | center = CGPoint(x: state.viewSize.width / 2, y: state.viewSize.height * 0.4) 549 | } 550 | 551 | let automatonState = AutomatonState( 552 | id: idFactory.generateID(), 553 | center: center, 554 | radius: 100 555 | ) 556 | state.automatonStatesDict[automatonState.id] = automatonState 557 | case let .viewSizeChanged(viewSize): 558 | state.viewSize = viewSize 559 | case .stateUpdated: 560 | return .none 561 | case .selectedEraser: 562 | state.tool = .eraser 563 | state.mode = .erasing 564 | state.isEraserSelected = true 565 | state.isPenSelected = false 566 | case .selectedPen: 567 | state.tool = .pen 568 | state.mode = .editing 569 | state.isEraserSelected = false 570 | state.isPenSelected = true 571 | case .clear: 572 | state.input = "" 573 | state.isAutomatonOutputVisible = false 574 | state.automatonStatesDict = [:] 575 | state.transitionsDict = [:] 576 | case .clearButtonPressed: 577 | state.isClearAlertPresented = true 578 | case .clearAlertDismissed: 579 | state.isClearAlertPresented = false 580 | case let .inputChanged(input): 581 | state.input = input 582 | .replacingOccurrences(of: " ", with: "") 583 | .trimmingCharacters(in: .whitespacesAndNewlines) 584 | case .removeLastInputSymbol: 585 | guard !state.input.isEmpty else { return .none } 586 | state.input.removeLast() 587 | case .simulateInput: 588 | guard 589 | let initialState = state.initialStates.first 590 | else { 591 | return showAutomatonOutput(.failure("No initial state")) 592 | } 593 | guard state.initialStates.count == 1 else { 594 | return showAutomatonOutput(.failure("Multiple initial states")) 595 | } 596 | let input = Array(state.input).map(String.init) 597 | // Create FA's alphabet based on symbols present in transitions 598 | let alphabetSymbols: [String] = Array( 599 | Set( 600 | state.transitions 601 | .flatMap { 602 | $0.symbols + ($0.currentSymbol.isEmpty ? [] : [$0.currentSymbol]) 603 | } 604 | ) 605 | ) 606 | guard 607 | input.allSatisfy(alphabetSymbols.contains) 608 | else { 609 | return showAutomatonOutput(.failure("Input symbols are not accepted by the automaton")) 610 | } 611 | let automatonStates = state.automatonStates 612 | let finalStates = state.finalStates 613 | let transitions = state.transitions 614 | return .task { 615 | do { 616 | try automataLibraryService.simulateInput( 617 | input, 618 | automatonStates, 619 | initialState, 620 | finalStates, 621 | alphabetSymbols, 622 | transitions 623 | ) 624 | return Action.simulateInputResult(.success(Empty())) 625 | } catch { 626 | return Action.simulateInputResult(.failure(.failed)) 627 | } 628 | } 629 | case .dismissToast: 630 | state.isAutomatonOutputVisible = false 631 | case .simulateInputResult(.success): 632 | return showAutomatonOutput(.success) 633 | case .simulateInputResult(.failure): 634 | return showAutomatonOutput(.failure(nil)) 635 | case let .stateSymbolChanged(automatonStateID, symbol): 636 | state.automatonStatesDict[automatonStateID]?.name = symbol 637 | .replacingOccurrences(of: " ", with: "") 638 | .trimmingCharacters(in: .whitespacesAndNewlines) 639 | case let .transitionSymbolChanged(transitionID, symbol): 640 | state.transitionsDict[transitionID]?.currentSymbol = symbol 641 | .replacingOccurrences(of: " ", with: "") 642 | .trimmingCharacters(in: .whitespacesAndNewlines) 643 | case let .transitionSymbolAdded(transitionID): 644 | guard 645 | let transition = state.transitionsDict[transitionID], 646 | !transition.currentSymbol.isEmpty, 647 | !transition.symbols.contains(transition.currentSymbol) 648 | else { return .none } 649 | state.transitionsDict[transitionID]?.symbols.append( 650 | transition.currentSymbol 651 | ) 652 | state.transitionsDict[transitionID]?.currentSymbol = "" 653 | case let .transitionSymbolRemoved(transitionID, symbol): 654 | state.transitionsDict[transitionID]?.symbols.removeAll(where: { $0 == symbol }) 655 | case let .automataShapeClassified(.success(.state(stateStroke))): 656 | let center = shapeService.center(stateStroke.controlPoints) 657 | let radius = shapeService.radius(stateStroke.controlPoints, center) 658 | 659 | let controlPoints: [CGPoint] = shapeService.circle( 660 | center, 661 | radius 662 | ) 663 | 664 | // Make a state final if this is a double circle 665 | if let automatonState = enclosingState(for: controlPoints) { 666 | guard !automatonState.isFinalState else { 667 | state.shouldDeleteLastStroke = true 668 | return .none 669 | } 670 | state.automatonStatesDict[automatonState.id]?.isFinalState = true 671 | // Connect to a transition without end state if one is close enough 672 | } else if var transition = closestTransitionWithoutEndState(for: controlPoints) { 673 | guard 674 | let startPoint = transition.startPoint, 675 | let tipPoint = transition.tipPoint 676 | else { return .none } 677 | let vector = Vector(startPoint, tipPoint) 678 | let center = vector.point(distance: radius, other: tipPoint) 679 | 680 | let automatonState = AutomatonState( 681 | id: idFactory.generateID(), 682 | center: center, 683 | radius: radius 684 | ) 685 | 686 | transition.endState = automatonState.id 687 | state.transitionsDict[transition.id] = transition 688 | 689 | state.automatonStatesDict[automatonState.id] = automatonState 690 | // Connect to a transition without start state if one is close enough 691 | } else if var transition = closestTransitionWithoutStartState(for: controlPoints) { 692 | guard 693 | let startPoint = transition.startPoint, 694 | let tipPoint = transition.tipPoint 695 | else { return .none } 696 | let vector = Vector(tipPoint, startPoint) 697 | let center = vector.point(distance: radius, other: startPoint) 698 | 699 | let automatonState = AutomatonState( 700 | id: idFactory.generateID(), 701 | center: center, 702 | radius: radius 703 | ) 704 | 705 | transition.startState = automatonState.id 706 | state.transitionsDict[transition.id] = transition 707 | 708 | state.automatonStatesDict[automatonState.id] = automatonState 709 | } else { 710 | let automatonState = AutomatonState( 711 | id: idFactory.generateID(), 712 | center: center, 713 | radius: radius 714 | ) 715 | state.automatonStatesDict[automatonState.id] = automatonState 716 | } 717 | case let .transitionFlexPointFinishedDragging(transitionID, finalFlexPoint): 718 | guard var transition = state.transitionsDict[transitionID] else { return .none } 719 | /// Update `currentFlexPoint` only when dragging has finished 720 | transition.currentFlexPoint = finalFlexPoint 721 | updateDraggedTransition(transition, flexPoint: finalFlexPoint) 722 | case let .transitionFlexPointChanged(transitionID, flexPoint): 723 | guard let transition = state.transitionsDict[transitionID] else { return .none } 724 | updateDraggedTransition(transition, flexPoint: flexPoint) 725 | case let .stateDragPointChanged(automatonStateID, currentDragPoint): 726 | state.automatonStatesDict[automatonStateID]?.currentDragPoint = currentDragPoint 727 | updateTransitionsAfterStateDragged(automatonStateID) 728 | case let .stateDragPointFinishedDragging(automatonStateID, currentDragPoint): 729 | state.automatonStatesDict[automatonStateID]?.currentDragPoint = currentDragPoint 730 | state.automatonStatesDict[automatonStateID]?.dragPoint = currentDragPoint 731 | updateTransitionsAfterStateDragged(automatonStateID) 732 | case let .toggleEpsilonInclusion(transitionID): 733 | state.transitionsDict[transitionID]?.includesEpsilon.toggle() 734 | case let .automataShapeClassified(.success(.transitionCycle(cycleStroke))): 735 | guard 736 | let strokeStartPoint = cycleStroke.controlPoints.first, 737 | let closestStateResult = closestState(from: strokeStartPoint) 738 | else { 739 | state.shouldDeleteLastStroke = true 740 | return .none 741 | } 742 | 743 | let center = closestStateResult.state.center 744 | // Angle between vector of center to topmost state's point and vector from center to the cycle's intersection point with the state. 745 | let radians = Vector( 746 | center, 747 | CGPoint( 748 | x: center.x, 749 | y: center.y + closestStateResult.state.radius 750 | ) 751 | ) 752 | .angle( 753 | with: Vector( 754 | center, 755 | closestStateResult.point 756 | ) 757 | ) 758 | 759 | let transition = AutomatonTransition( 760 | id: idFactory.generateID(), 761 | startState: closestStateResult.state.id, 762 | endState: closestStateResult.state.id, 763 | type: .cycle( 764 | closestStateResult.point, 765 | center: center, 766 | radians: radians 767 | ) 768 | ) 769 | state.transitionsDict[transition.id] = transition 770 | case let .automataShapeClassified(.success(.transition(stroke))): 771 | guard 772 | let strokeStartPoint = stroke.controlPoints.first 773 | else { return .none } 774 | 775 | let closestStartStateResult = closestState(from: strokeStartPoint) 776 | 777 | let furthestPoint = stroke.controlPoints.furthestPoint(from: closestStartStateResult?.point ?? strokeStartPoint) 778 | let closestEndStateResult = closestState( 779 | from: furthestPoint 780 | ) 781 | 782 | let startPoint: CGPoint 783 | let tipPoint: CGPoint 784 | let startState: AutomatonState? 785 | let endState: AutomatonState? 786 | // Connect transition to start or end state if any exists. 787 | // If both exist, then connect it to the closer one. 788 | if 789 | let closestStartStateResult = closestStartStateResult, 790 | let closestEndStateResult = closestEndStateResult, 791 | closestStartStateResult.state == closestEndStateResult.state { 792 | let startIsCloser = closestStartStateResult.distance < closestEndStateResult.distance 793 | startPoint = startIsCloser ? closestStartStateResult.point : strokeStartPoint 794 | tipPoint = startIsCloser ? furthestPoint : closestEndStateResult.point 795 | startState = startIsCloser ? closestStartStateResult.state : nil 796 | endState = startIsCloser ? nil : closestEndStateResult.state 797 | } else { 798 | startPoint = closestStartStateResult?.point ?? strokeStartPoint 799 | tipPoint = closestEndStateResult?.point ?? furthestPoint 800 | startState = closestStartStateResult?.state 801 | endState = closestEndStateResult?.state 802 | } 803 | 804 | let flexPoint = CGPoint( 805 | x: (startPoint.x + tipPoint.x) / 2, 806 | y: (startPoint.y + tipPoint.y) / 2 807 | ) 808 | 809 | let transition = AutomatonTransition( 810 | id: idFactory.generateID(), 811 | startState: startState?.id, 812 | endState: endState?.id, 813 | type: .regular( 814 | startPoint: startPoint, 815 | tipPoint: tipPoint, 816 | flexPoint: flexPoint 817 | ), 818 | currentFlexPoint: flexPoint 819 | ) 820 | state.transitionsDict[transition.id] = transition 821 | case .automataShapeClassified(.failure): 822 | state.shouldDeleteLastStroke = true 823 | case let .strokesChanged(strokes): 824 | guard let stroke = strokes.last else { return .none } 825 | return .task { 826 | do { 827 | let shape = try await automataClassifierService.recognizeStroke(stroke) 828 | return Action.automataShapeClassified(.success(shape)) 829 | } catch { 830 | return Action.automataShapeClassified(.failure(.shapeNotRecognized)) 831 | } 832 | } 833 | } 834 | 835 | return .none 836 | } 837 | } 838 | --------------------------------------------------------------------------------