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