├── Examples ├── Mac │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ └── Main.storyboard │ ├── Info.plist │ └── ViewController.swift ├── SoulverTextKit.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── Mac Example.xcscheme └── iOS │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ ├── SceneDelegate.swift │ └── ViewController.swift ├── Images ├── AfterEquals.png ├── AfterPipe.png ├── AfterTab.png └── Example.gif ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SoulverTextKit │ ├── Helpers │ └── StringByParagraphs.swift │ └── SoulverTextKit.swift └── Tests ├── LinuxMain.swift └── SoulverTextKitTests ├── SoulverTextKitTests.swift └── XCTestManifests.swift /Examples/Mac/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SoulverTextKit 4 | // 5 | // Created by Zac Cohan on 9/1/21. 6 | // 7 | 8 | import Cocoa 9 | 10 | @main 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | 13 | func applicationDidFinishLaunching(_ aNotification: Notification) { 14 | // Insert code here to initialize your application 15 | } 16 | 17 | func applicationWillTerminate(_ aNotification: Notification) { 18 | // Insert code here to tear down your application 19 | } 20 | 21 | 22 | } 23 | 24 | -------------------------------------------------------------------------------- /Examples/Mac/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 | -------------------------------------------------------------------------------- /Examples/Mac/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Examples/Mac/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Mac/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | -------------------------------------------------------------------------------- /Examples/Mac/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSMainStoryboardFile 26 | Main 27 | NSPrincipalClass 28 | NSApplication 29 | 30 | 31 | -------------------------------------------------------------------------------- /Examples/Mac/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // SoulverTextKit 4 | // 5 | // Created by Zac Cohan on 9/1/21. 6 | // 7 | 8 | import Cocoa 9 | import SoulverTextKit 10 | 11 | class ViewController: NSViewController { 12 | 13 | /// Grab a standard text view 14 | @IBOutlet var textView: NSTextView! 15 | 16 | /// Create one of these things 17 | var paragraphCalculator: SoulverTextKit.ParagraphCalculator! 18 | 19 | /// Choose what character distinguishes a calculating paragraph 20 | let style = AnswerPosition.afterTab 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | 25 | self.setupSoulverTextKit() 26 | 27 | // Looks better 28 | self.textView.textContainerInset = NSSize(width: 10.0, height: 15.0) 29 | 30 | } 31 | 32 | func setupSoulverTextKit() { 33 | 34 | paragraphCalculator = ParagraphCalculator(answerPosition: self.style, textStorage: self.textView.textStorage!, textContainer: self.textView.textContainer!) 35 | 36 | // Setup the text view to send us relevant delegate messages 37 | self.textView.delegate = self 38 | self.textView.layoutManager!.delegate = self 39 | 40 | // Set some default expressions 41 | self.textView.string = [ 42 | "123 + 456", 43 | "10 USD in EUR", 44 | "today + 3 weeks" 45 | ].expressionStringFor(style: self.style) 46 | 47 | // let soulverTextKit know we changed the textView's text 48 | paragraphCalculator.textDidChange() 49 | 50 | 51 | } 52 | 53 | } 54 | 55 | extension ViewController : NSLayoutManagerDelegate, NSTextViewDelegate { 56 | 57 | func textDidChange(_ notification: Notification) { 58 | 59 | // Let us know when the text changes, so we can evaluate any changed paragraph if necessary 60 | paragraphCalculator.textDidChange() 61 | } 62 | 63 | func layoutManager(_ layoutManager: NSLayoutManager, textContainer: NSTextContainer, didChangeGeometryFrom oldSize: NSSize) { 64 | 65 | // Let us know when the text view changes size, so we can change update the formatting if necessary 66 | paragraphCalculator.layoutDidChange() 67 | } 68 | 69 | func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { 70 | 71 | // Check with us to see if the user should be able to edit parts of the paragraph. For example, we don't allow the user to edit results on Soulver lines themselves 72 | 73 | switch paragraphCalculator.shouldAllowReplacementFor(affectedCharRange: affectedCharRange, replacementString: replacementString) { 74 | case .allow: 75 | return true 76 | case .deny: 77 | NSSound.beep() 78 | return false 79 | case .setIntertionPoint(range: let range): 80 | textView.setSelectedRange(range) 81 | return false 82 | } 83 | 84 | } 85 | 86 | 87 | } 88 | 89 | 90 | -------------------------------------------------------------------------------- /Examples/SoulverTextKit.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 11CEDE5425AB5A400045AB53 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CEDE5325AB5A400045AB53 /* AppDelegate.swift */; }; 11 | 11CEDE5625AB5A400045AB53 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CEDE5525AB5A400045AB53 /* SceneDelegate.swift */; }; 12 | 11CEDE5825AB5A400045AB53 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CEDE5725AB5A400045AB53 /* ViewController.swift */; }; 13 | 11CEDE5B25AB5A400045AB53 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 11CEDE5925AB5A400045AB53 /* Main.storyboard */; }; 14 | 11CEDE5D25AB5A410045AB53 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 11CEDE5C25AB5A410045AB53 /* Assets.xcassets */; }; 15 | 11CEDE6025AB5A410045AB53 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 11CEDE5E25AB5A410045AB53 /* LaunchScreen.storyboard */; }; 16 | 11D5A5C925C759DC00941F25 /* SoulverTextKit in Frameworks */ = {isa = PBXBuildFile; productRef = 11D5A5C825C759DC00941F25 /* SoulverTextKit */; }; 17 | 11D5A5CB25C759E200941F25 /* SoulverTextKit in Frameworks */ = {isa = PBXBuildFile; productRef = 11D5A5CA25C759E200941F25 /* SoulverTextKit */; }; 18 | 11E0805225AA105100DD964C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E0805125AA105100DD964C /* AppDelegate.swift */; }; 19 | 11E0805425AA105100DD964C /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E0805325AA105100DD964C /* ViewController.swift */; }; 20 | 11E0805625AA105200DD964C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 11E0805525AA105200DD964C /* Assets.xcassets */; }; 21 | 11E0805925AA105200DD964C /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 11E0805725AA105200DD964C /* Main.storyboard */; }; 22 | /* End PBXBuildFile section */ 23 | 24 | /* Begin PBXCopyFilesBuildPhase section */ 25 | 11CEDE6E25AB5B4A0045AB53 /* Embed Frameworks */ = { 26 | isa = PBXCopyFilesBuildPhase; 27 | buildActionMask = 2147483647; 28 | dstPath = ""; 29 | dstSubfolderSpec = 10; 30 | files = ( 31 | ); 32 | name = "Embed Frameworks"; 33 | runOnlyForDeploymentPostprocessing = 0; 34 | }; 35 | 11E080A025AA2E1000DD964C /* Embed Frameworks */ = { 36 | isa = PBXCopyFilesBuildPhase; 37 | buildActionMask = 2147483647; 38 | dstPath = ""; 39 | dstSubfolderSpec = 10; 40 | files = ( 41 | ); 42 | name = "Embed Frameworks"; 43 | runOnlyForDeploymentPostprocessing = 0; 44 | }; 45 | /* End PBXCopyFilesBuildPhase section */ 46 | 47 | /* Begin PBXFileReference section */ 48 | 11CEDE5125AB5A400045AB53 /* iOS Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "iOS Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 49 | 11CEDE5325AB5A400045AB53 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 50 | 11CEDE5525AB5A400045AB53 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 51 | 11CEDE5725AB5A400045AB53 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 52 | 11CEDE5A25AB5A400045AB53 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 53 | 11CEDE5C25AB5A410045AB53 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 54 | 11CEDE5F25AB5A410045AB53 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 55 | 11CEDE6125AB5A410045AB53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 56 | 11D5A5C725C7598100941F25 /* .. */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ..; sourceTree = ""; }; 57 | 11E0804E25AA105100DD964C /* Mac Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mac Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 58 | 11E0805125AA105100DD964C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 59 | 11E0805325AA105100DD964C /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 60 | 11E0805525AA105200DD964C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 61 | 11E0805825AA105200DD964C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 62 | 11E0805A25AA105200DD964C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 63 | /* End PBXFileReference section */ 64 | 65 | /* Begin PBXFrameworksBuildPhase section */ 66 | 11CEDE4E25AB5A400045AB53 /* Frameworks */ = { 67 | isa = PBXFrameworksBuildPhase; 68 | buildActionMask = 2147483647; 69 | files = ( 70 | 11D5A5CB25C759E200941F25 /* SoulverTextKit in Frameworks */, 71 | ); 72 | runOnlyForDeploymentPostprocessing = 0; 73 | }; 74 | 11E0804B25AA105100DD964C /* Frameworks */ = { 75 | isa = PBXFrameworksBuildPhase; 76 | buildActionMask = 2147483647; 77 | files = ( 78 | 11D5A5C925C759DC00941F25 /* SoulverTextKit in Frameworks */, 79 | ); 80 | runOnlyForDeploymentPostprocessing = 0; 81 | }; 82 | /* End PBXFrameworksBuildPhase section */ 83 | 84 | /* Begin PBXGroup section */ 85 | 11CEDE5225AB5A400045AB53 /* iOS */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | 11CEDE5325AB5A400045AB53 /* AppDelegate.swift */, 89 | 11CEDE5525AB5A400045AB53 /* SceneDelegate.swift */, 90 | 11CEDE5725AB5A400045AB53 /* ViewController.swift */, 91 | 11CEDE5925AB5A400045AB53 /* Main.storyboard */, 92 | 11CEDE5C25AB5A410045AB53 /* Assets.xcassets */, 93 | 11CEDE5E25AB5A410045AB53 /* LaunchScreen.storyboard */, 94 | 11CEDE6125AB5A410045AB53 /* Info.plist */, 95 | ); 96 | path = iOS; 97 | sourceTree = ""; 98 | }; 99 | 11E0804525AA105100DD964C = { 100 | isa = PBXGroup; 101 | children = ( 102 | 11D5A5C725C7598100941F25 /* .. */, 103 | 11E0805025AA105100DD964C /* Mac */, 104 | 11CEDE5225AB5A400045AB53 /* iOS */, 105 | 11E0804F25AA105100DD964C /* Products */, 106 | ); 107 | sourceTree = ""; 108 | }; 109 | 11E0804F25AA105100DD964C /* Products */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | 11E0804E25AA105100DD964C /* Mac Example.app */, 113 | 11CEDE5125AB5A400045AB53 /* iOS Example.app */, 114 | ); 115 | name = Products; 116 | sourceTree = ""; 117 | }; 118 | 11E0805025AA105100DD964C /* Mac */ = { 119 | isa = PBXGroup; 120 | children = ( 121 | 11E0805125AA105100DD964C /* AppDelegate.swift */, 122 | 11E0805325AA105100DD964C /* ViewController.swift */, 123 | 11E0805525AA105200DD964C /* Assets.xcassets */, 124 | 11E0805725AA105200DD964C /* Main.storyboard */, 125 | 11E0805A25AA105200DD964C /* Info.plist */, 126 | ); 127 | path = Mac; 128 | sourceTree = ""; 129 | }; 130 | /* End PBXGroup section */ 131 | 132 | /* Begin PBXNativeTarget section */ 133 | 11CEDE5025AB5A400045AB53 /* iOS Example */ = { 134 | isa = PBXNativeTarget; 135 | buildConfigurationList = 11CEDE6225AB5A410045AB53 /* Build configuration list for PBXNativeTarget "iOS Example" */; 136 | buildPhases = ( 137 | 11CEDE4D25AB5A400045AB53 /* Sources */, 138 | 11CEDE4E25AB5A400045AB53 /* Frameworks */, 139 | 11CEDE4F25AB5A400045AB53 /* Resources */, 140 | 11CEDE6E25AB5B4A0045AB53 /* Embed Frameworks */, 141 | ); 142 | buildRules = ( 143 | ); 144 | dependencies = ( 145 | ); 146 | name = "iOS Example"; 147 | packageProductDependencies = ( 148 | 11D5A5CA25C759E200941F25 /* SoulverTextKit */, 149 | ); 150 | productName = "iOS Example"; 151 | productReference = 11CEDE5125AB5A400045AB53 /* iOS Example.app */; 152 | productType = "com.apple.product-type.application"; 153 | }; 154 | 11E0804D25AA105100DD964C /* Mac Example */ = { 155 | isa = PBXNativeTarget; 156 | buildConfigurationList = 11E0805E25AA105200DD964C /* Build configuration list for PBXNativeTarget "Mac Example" */; 157 | buildPhases = ( 158 | 11E0804A25AA105100DD964C /* Sources */, 159 | 11E0804B25AA105100DD964C /* Frameworks */, 160 | 11E0804C25AA105100DD964C /* Resources */, 161 | 11E080A025AA2E1000DD964C /* Embed Frameworks */, 162 | ); 163 | buildRules = ( 164 | ); 165 | dependencies = ( 166 | ); 167 | name = "Mac Example"; 168 | packageProductDependencies = ( 169 | 11D5A5C825C759DC00941F25 /* SoulverTextKit */, 170 | ); 171 | productName = SoulverTextKit; 172 | productReference = 11E0804E25AA105100DD964C /* Mac Example.app */; 173 | productType = "com.apple.product-type.application"; 174 | }; 175 | /* End PBXNativeTarget section */ 176 | 177 | /* Begin PBXProject section */ 178 | 11E0804625AA105100DD964C /* Project object */ = { 179 | isa = PBXProject; 180 | attributes = { 181 | LastSwiftUpdateCheck = 1230; 182 | LastUpgradeCheck = 1240; 183 | TargetAttributes = { 184 | 11CEDE5025AB5A400045AB53 = { 185 | CreatedOnToolsVersion = 12.3; 186 | }; 187 | 11E0804D25AA105100DD964C = { 188 | CreatedOnToolsVersion = 12.3; 189 | }; 190 | }; 191 | }; 192 | buildConfigurationList = 11E0804925AA105100DD964C /* Build configuration list for PBXProject "SoulverTextKit" */; 193 | compatibilityVersion = "Xcode 9.3"; 194 | developmentRegion = en; 195 | hasScannedForEncodings = 0; 196 | knownRegions = ( 197 | en, 198 | Base, 199 | ); 200 | mainGroup = 11E0804525AA105100DD964C; 201 | packageReferences = ( 202 | ); 203 | productRefGroup = 11E0804F25AA105100DD964C /* Products */; 204 | projectDirPath = ""; 205 | projectRoot = ""; 206 | targets = ( 207 | 11E0804D25AA105100DD964C /* Mac Example */, 208 | 11CEDE5025AB5A400045AB53 /* iOS Example */, 209 | ); 210 | }; 211 | /* End PBXProject section */ 212 | 213 | /* Begin PBXResourcesBuildPhase section */ 214 | 11CEDE4F25AB5A400045AB53 /* Resources */ = { 215 | isa = PBXResourcesBuildPhase; 216 | buildActionMask = 2147483647; 217 | files = ( 218 | 11CEDE6025AB5A410045AB53 /* LaunchScreen.storyboard in Resources */, 219 | 11CEDE5D25AB5A410045AB53 /* Assets.xcassets in Resources */, 220 | 11CEDE5B25AB5A400045AB53 /* Main.storyboard in Resources */, 221 | ); 222 | runOnlyForDeploymentPostprocessing = 0; 223 | }; 224 | 11E0804C25AA105100DD964C /* Resources */ = { 225 | isa = PBXResourcesBuildPhase; 226 | buildActionMask = 2147483647; 227 | files = ( 228 | 11E0805625AA105200DD964C /* Assets.xcassets in Resources */, 229 | 11E0805925AA105200DD964C /* Main.storyboard in Resources */, 230 | ); 231 | runOnlyForDeploymentPostprocessing = 0; 232 | }; 233 | /* End PBXResourcesBuildPhase section */ 234 | 235 | /* Begin PBXSourcesBuildPhase section */ 236 | 11CEDE4D25AB5A400045AB53 /* Sources */ = { 237 | isa = PBXSourcesBuildPhase; 238 | buildActionMask = 2147483647; 239 | files = ( 240 | 11CEDE5825AB5A400045AB53 /* ViewController.swift in Sources */, 241 | 11CEDE5425AB5A400045AB53 /* AppDelegate.swift in Sources */, 242 | 11CEDE5625AB5A400045AB53 /* SceneDelegate.swift in Sources */, 243 | ); 244 | runOnlyForDeploymentPostprocessing = 0; 245 | }; 246 | 11E0804A25AA105100DD964C /* Sources */ = { 247 | isa = PBXSourcesBuildPhase; 248 | buildActionMask = 2147483647; 249 | files = ( 250 | 11E0805425AA105100DD964C /* ViewController.swift in Sources */, 251 | 11E0805225AA105100DD964C /* AppDelegate.swift in Sources */, 252 | ); 253 | runOnlyForDeploymentPostprocessing = 0; 254 | }; 255 | /* End PBXSourcesBuildPhase section */ 256 | 257 | /* Begin PBXVariantGroup section */ 258 | 11CEDE5925AB5A400045AB53 /* Main.storyboard */ = { 259 | isa = PBXVariantGroup; 260 | children = ( 261 | 11CEDE5A25AB5A400045AB53 /* Base */, 262 | ); 263 | name = Main.storyboard; 264 | sourceTree = ""; 265 | }; 266 | 11CEDE5E25AB5A410045AB53 /* LaunchScreen.storyboard */ = { 267 | isa = PBXVariantGroup; 268 | children = ( 269 | 11CEDE5F25AB5A410045AB53 /* Base */, 270 | ); 271 | name = LaunchScreen.storyboard; 272 | sourceTree = ""; 273 | }; 274 | 11E0805725AA105200DD964C /* Main.storyboard */ = { 275 | isa = PBXVariantGroup; 276 | children = ( 277 | 11E0805825AA105200DD964C /* Base */, 278 | ); 279 | name = Main.storyboard; 280 | sourceTree = ""; 281 | }; 282 | /* End PBXVariantGroup section */ 283 | 284 | /* Begin XCBuildConfiguration section */ 285 | 11CEDE6325AB5A410045AB53 /* Debug */ = { 286 | isa = XCBuildConfiguration; 287 | buildSettings = { 288 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 289 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 290 | CODE_SIGN_STYLE = Automatic; 291 | DEVELOPMENT_TEAM = SBA8TXW9N6; 292 | INFOPLIST_FILE = iOS/Info.plist; 293 | IPHONEOS_DEPLOYMENT_TARGET = 14.3; 294 | LD_RUNPATH_SEARCH_PATHS = ( 295 | "$(inherited)", 296 | "@executable_path/Frameworks", 297 | ); 298 | PRODUCT_BUNDLE_IDENTIFIER = app.soulver.iOS.SoulverTextKitExample; 299 | PRODUCT_NAME = "$(TARGET_NAME)"; 300 | SDKROOT = iphoneos; 301 | SWIFT_VERSION = 5.0; 302 | TARGETED_DEVICE_FAMILY = "1,2"; 303 | }; 304 | name = Debug; 305 | }; 306 | 11CEDE6425AB5A410045AB53 /* Release */ = { 307 | isa = XCBuildConfiguration; 308 | buildSettings = { 309 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 310 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 311 | CODE_SIGN_STYLE = Automatic; 312 | DEVELOPMENT_TEAM = SBA8TXW9N6; 313 | INFOPLIST_FILE = iOS/Info.plist; 314 | IPHONEOS_DEPLOYMENT_TARGET = 14.3; 315 | LD_RUNPATH_SEARCH_PATHS = ( 316 | "$(inherited)", 317 | "@executable_path/Frameworks", 318 | ); 319 | PRODUCT_BUNDLE_IDENTIFIER = app.soulver.iOS.SoulverTextKitExample; 320 | PRODUCT_NAME = "$(TARGET_NAME)"; 321 | SDKROOT = iphoneos; 322 | SWIFT_VERSION = 5.0; 323 | TARGETED_DEVICE_FAMILY = "1,2"; 324 | VALIDATE_PRODUCT = YES; 325 | }; 326 | name = Release; 327 | }; 328 | 11E0805C25AA105200DD964C /* Debug */ = { 329 | isa = XCBuildConfiguration; 330 | buildSettings = { 331 | ALWAYS_SEARCH_USER_PATHS = NO; 332 | CLANG_ANALYZER_NONNULL = YES; 333 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 334 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 335 | CLANG_CXX_LIBRARY = "libc++"; 336 | CLANG_ENABLE_MODULES = YES; 337 | CLANG_ENABLE_OBJC_ARC = YES; 338 | CLANG_ENABLE_OBJC_WEAK = YES; 339 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 340 | CLANG_WARN_BOOL_CONVERSION = YES; 341 | CLANG_WARN_COMMA = YES; 342 | CLANG_WARN_CONSTANT_CONVERSION = YES; 343 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 344 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 345 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 346 | CLANG_WARN_EMPTY_BODY = YES; 347 | CLANG_WARN_ENUM_CONVERSION = YES; 348 | CLANG_WARN_INFINITE_RECURSION = YES; 349 | CLANG_WARN_INT_CONVERSION = YES; 350 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 351 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 352 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 353 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 354 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 355 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 356 | CLANG_WARN_STRICT_PROTOTYPES = YES; 357 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 358 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 359 | CLANG_WARN_UNREACHABLE_CODE = YES; 360 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 361 | COPY_PHASE_STRIP = NO; 362 | DEBUG_INFORMATION_FORMAT = dwarf; 363 | ENABLE_STRICT_OBJC_MSGSEND = YES; 364 | ENABLE_TESTABILITY = YES; 365 | GCC_C_LANGUAGE_STANDARD = gnu11; 366 | GCC_DYNAMIC_NO_PIC = NO; 367 | GCC_NO_COMMON_BLOCKS = YES; 368 | GCC_OPTIMIZATION_LEVEL = 0; 369 | GCC_PREPROCESSOR_DEFINITIONS = ( 370 | "DEBUG=1", 371 | "$(inherited)", 372 | ); 373 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 374 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 375 | GCC_WARN_UNDECLARED_SELECTOR = YES; 376 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 377 | GCC_WARN_UNUSED_FUNCTION = YES; 378 | GCC_WARN_UNUSED_VARIABLE = YES; 379 | MACOSX_DEPLOYMENT_TARGET = 10.15; 380 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 381 | MTL_FAST_MATH = YES; 382 | ONLY_ACTIVE_ARCH = YES; 383 | SDKROOT = macosx; 384 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 385 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 386 | }; 387 | name = Debug; 388 | }; 389 | 11E0805D25AA105200DD964C /* Release */ = { 390 | isa = XCBuildConfiguration; 391 | buildSettings = { 392 | ALWAYS_SEARCH_USER_PATHS = NO; 393 | CLANG_ANALYZER_NONNULL = YES; 394 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 395 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 396 | CLANG_CXX_LIBRARY = "libc++"; 397 | CLANG_ENABLE_MODULES = YES; 398 | CLANG_ENABLE_OBJC_ARC = YES; 399 | CLANG_ENABLE_OBJC_WEAK = YES; 400 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 401 | CLANG_WARN_BOOL_CONVERSION = YES; 402 | CLANG_WARN_COMMA = YES; 403 | CLANG_WARN_CONSTANT_CONVERSION = YES; 404 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 405 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 406 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 407 | CLANG_WARN_EMPTY_BODY = YES; 408 | CLANG_WARN_ENUM_CONVERSION = YES; 409 | CLANG_WARN_INFINITE_RECURSION = YES; 410 | CLANG_WARN_INT_CONVERSION = YES; 411 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 412 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 413 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 414 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 415 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 416 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 417 | CLANG_WARN_STRICT_PROTOTYPES = YES; 418 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 419 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 420 | CLANG_WARN_UNREACHABLE_CODE = YES; 421 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 422 | COPY_PHASE_STRIP = NO; 423 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 424 | ENABLE_NS_ASSERTIONS = NO; 425 | ENABLE_STRICT_OBJC_MSGSEND = YES; 426 | GCC_C_LANGUAGE_STANDARD = gnu11; 427 | GCC_NO_COMMON_BLOCKS = YES; 428 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 429 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 430 | GCC_WARN_UNDECLARED_SELECTOR = YES; 431 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 432 | GCC_WARN_UNUSED_FUNCTION = YES; 433 | GCC_WARN_UNUSED_VARIABLE = YES; 434 | MACOSX_DEPLOYMENT_TARGET = 10.15; 435 | MTL_ENABLE_DEBUG_INFO = NO; 436 | MTL_FAST_MATH = YES; 437 | SDKROOT = macosx; 438 | SWIFT_COMPILATION_MODE = wholemodule; 439 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 440 | }; 441 | name = Release; 442 | }; 443 | 11E0805F25AA105200DD964C /* Debug */ = { 444 | isa = XCBuildConfiguration; 445 | buildSettings = { 446 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 447 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 448 | CODE_SIGN_STYLE = Automatic; 449 | COMBINE_HIDPI_IMAGES = YES; 450 | DEVELOPMENT_TEAM = SBA8TXW9N6; 451 | ENABLE_HARDENED_RUNTIME = YES; 452 | INFOPLIST_FILE = Mac/Info.plist; 453 | LD_RUNPATH_SEARCH_PATHS = ( 454 | "$(inherited)", 455 | "@executable_path/../Frameworks", 456 | ); 457 | PRODUCT_BUNDLE_IDENTIFIER = app.soulver.mac.SoulverTextKitExample; 458 | PRODUCT_NAME = "$(TARGET_NAME)"; 459 | SWIFT_VERSION = 5.0; 460 | }; 461 | name = Debug; 462 | }; 463 | 11E0806025AA105200DD964C /* Release */ = { 464 | isa = XCBuildConfiguration; 465 | buildSettings = { 466 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 467 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 468 | CODE_SIGN_STYLE = Automatic; 469 | COMBINE_HIDPI_IMAGES = YES; 470 | DEVELOPMENT_TEAM = SBA8TXW9N6; 471 | ENABLE_HARDENED_RUNTIME = YES; 472 | INFOPLIST_FILE = Mac/Info.plist; 473 | LD_RUNPATH_SEARCH_PATHS = ( 474 | "$(inherited)", 475 | "@executable_path/../Frameworks", 476 | ); 477 | PRODUCT_BUNDLE_IDENTIFIER = app.soulver.mac.SoulverTextKitExample; 478 | PRODUCT_NAME = "$(TARGET_NAME)"; 479 | SWIFT_VERSION = 5.0; 480 | }; 481 | name = Release; 482 | }; 483 | /* End XCBuildConfiguration section */ 484 | 485 | /* Begin XCConfigurationList section */ 486 | 11CEDE6225AB5A410045AB53 /* Build configuration list for PBXNativeTarget "iOS Example" */ = { 487 | isa = XCConfigurationList; 488 | buildConfigurations = ( 489 | 11CEDE6325AB5A410045AB53 /* Debug */, 490 | 11CEDE6425AB5A410045AB53 /* Release */, 491 | ); 492 | defaultConfigurationIsVisible = 0; 493 | defaultConfigurationName = Release; 494 | }; 495 | 11E0804925AA105100DD964C /* Build configuration list for PBXProject "SoulverTextKit" */ = { 496 | isa = XCConfigurationList; 497 | buildConfigurations = ( 498 | 11E0805C25AA105200DD964C /* Debug */, 499 | 11E0805D25AA105200DD964C /* Release */, 500 | ); 501 | defaultConfigurationIsVisible = 0; 502 | defaultConfigurationName = Release; 503 | }; 504 | 11E0805E25AA105200DD964C /* Build configuration list for PBXNativeTarget "Mac Example" */ = { 505 | isa = XCConfigurationList; 506 | buildConfigurations = ( 507 | 11E0805F25AA105200DD964C /* Debug */, 508 | 11E0806025AA105200DD964C /* Release */, 509 | ); 510 | defaultConfigurationIsVisible = 0; 511 | defaultConfigurationName = Release; 512 | }; 513 | /* End XCConfigurationList section */ 514 | 515 | /* Begin XCSwiftPackageProductDependency section */ 516 | 11D5A5C825C759DC00941F25 /* SoulverTextKit */ = { 517 | isa = XCSwiftPackageProductDependency; 518 | productName = SoulverTextKit; 519 | }; 520 | 11D5A5CA25C759E200941F25 /* SoulverTextKit */ = { 521 | isa = XCSwiftPackageProductDependency; 522 | productName = SoulverTextKit; 523 | }; 524 | /* End XCSwiftPackageProductDependency section */ 525 | }; 526 | rootObject = 11E0804625AA105100DD964C /* Project object */; 527 | } 528 | -------------------------------------------------------------------------------- /Examples/SoulverTextKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/SoulverTextKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/SoulverTextKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SoulverCore", 6 | "repositoryURL": "https://github.com/soulverteam/SoulverCore", 7 | "state": { 8 | "branch": null, 9 | "revision": "659f47b1693799445ed08e8713d4241eced1b170", 10 | "version": "1.2.1" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Examples/SoulverTextKit.xcodeproj/xcshareddata/xcschemes/Mac Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Examples/iOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // iOS Example 4 | // 5 | // Created by Zac Cohan on 10/1/21. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 14 | // Override point for customization after application launch. 15 | return true 16 | } 17 | 18 | // MARK: UISceneSession Lifecycle 19 | 20 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 21 | // Called when a new scene session is being created. 22 | // Use this method to select a configuration to create the new scene with. 23 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 24 | } 25 | 26 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 27 | // Called when the user discards a scene session. 28 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 29 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 30 | } 31 | 32 | 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Examples/iOS/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 | -------------------------------------------------------------------------------- /Examples/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Examples/iOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/iOS/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Examples/iOS/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /Examples/iOS/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 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | UISceneStoryboardFile 37 | Main 38 | 39 | 40 | 41 | 42 | UIApplicationSupportsIndirectInputEvents 43 | 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIMainStoryboardFile 47 | Main 48 | UIRequiredDeviceCapabilities 49 | 50 | armv7 51 | 52 | UISupportedInterfaceOrientations 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationLandscapeLeft 56 | UIInterfaceOrientationLandscapeRight 57 | 58 | UISupportedInterfaceOrientations~ipad 59 | 60 | UIInterfaceOrientationPortrait 61 | UIInterfaceOrientationPortraitUpsideDown 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /Examples/iOS/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // iOS Example 4 | // 5 | // Created by Zac Cohan on 10/1/21. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 15 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 16 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 17 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 18 | guard let _ = (scene as? UIWindowScene) else { return } 19 | } 20 | 21 | func sceneDidDisconnect(_ scene: UIScene) { 22 | // Called as the scene is being released by the system. 23 | // This occurs shortly after the scene enters the background, or when its session is discarded. 24 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 25 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 26 | } 27 | 28 | func sceneDidBecomeActive(_ scene: UIScene) { 29 | // Called when the scene has moved from an inactive state to an active state. 30 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 31 | } 32 | 33 | func sceneWillResignActive(_ scene: UIScene) { 34 | // Called when the scene will move from an active state to an inactive state. 35 | // This may occur due to temporary interruptions (ex. an incoming phone call). 36 | } 37 | 38 | func sceneWillEnterForeground(_ scene: UIScene) { 39 | // Called as the scene transitions from the background to the foreground. 40 | // Use this method to undo the changes made on entering the background. 41 | } 42 | 43 | func sceneDidEnterBackground(_ scene: UIScene) { 44 | // Called as the scene transitions from the foreground to the background. 45 | // Use this method to save data, release shared resources, and store enough scene-specific state information 46 | // to restore the scene back to its current state. 47 | } 48 | 49 | 50 | } 51 | 52 | -------------------------------------------------------------------------------- /Examples/iOS/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // iOS Example 4 | // 5 | // Created by Zac Cohan on 10/1/21. 6 | // 7 | 8 | import UIKit 9 | import SoulverTextKit 10 | 11 | class ViewController: UIViewController { 12 | 13 | /// Grab a standard text view 14 | @IBOutlet weak var textView: UITextView! 15 | 16 | /// Create one of these things 17 | var paragraphCalculator: SoulverTextKit.ParagraphCalculator! 18 | 19 | /// Choose what character distinguishes a calculating paragraph 20 | let answerPosition = AnswerPosition.afterEquals 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | 25 | self.setupSoulverTextKit() 26 | 27 | // Looks better 28 | self.textView.textContainerInset = UIEdgeInsets(top: 20.0, left: 5.0, bottom: 0.0, right: 5.0) 29 | 30 | } 31 | 32 | func setupSoulverTextKit() { 33 | 34 | paragraphCalculator = ParagraphCalculator(answerPosition: answerPosition, textStorage: self.textView.textStorage, textContainer: self.textView.textContainer) 35 | 36 | // Setup the text view to send us relevant delegate messages 37 | self.textView.delegate = self 38 | self.textView.layoutManager.delegate = self 39 | 40 | // Set somef default expressions 41 | self.textView.text = [ 42 | "123 + 456", 43 | "10 USD in EUR", 44 | "today + 3 weeks" 45 | ].expressionStringFor(style: self.answerPosition) 46 | 47 | // let soulverTextKit know we changed the textView's text 48 | paragraphCalculator.textDidChange() 49 | 50 | } 51 | 52 | } 53 | 54 | extension ViewController : NSLayoutManagerDelegate, UITextViewDelegate { 55 | 56 | 57 | func textViewDidChange(_ textView: UITextView) { 58 | 59 | // Let us know when the text changes, so we can evaluate any changed paragraphs if necessary 60 | paragraphCalculator.textDidChange() 61 | } 62 | 63 | func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { 64 | 65 | // Check with us to see if the user should be able to edit parts of the paragraph. For example, we don't allow the user to edit results on Soulver lines themselves 66 | 67 | switch paragraphCalculator.shouldAllowReplacementFor(affectedCharRange: range, replacementString: text) { 68 | case .allow: 69 | return true 70 | case .deny: 71 | return false 72 | case .setIntertionPoint(range: let range): 73 | textView.selectedRange = range 74 | return false 75 | } 76 | 77 | } 78 | 79 | func layoutManager(_ layoutManager: NSLayoutManager, textContainer: NSTextContainer, didChangeGeometryFrom oldSize: CGSize) { 80 | 81 | // Let us know when the text view changes size, so we can change update the formatting if necessary 82 | paragraphCalculator.layoutDidChange() 83 | } 84 | 85 | 86 | } 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /Images/AfterEquals.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulverteam/SoulverTextKit/fe9f909c6652b7caf4973465f4b3fc5d9647f219/Images/AfterEquals.png -------------------------------------------------------------------------------- /Images/AfterPipe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulverteam/SoulverTextKit/fe9f909c6652b7caf4973465f4b3fc5d9647f219/Images/AfterPipe.png -------------------------------------------------------------------------------- /Images/AfterTab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulverteam/SoulverTextKit/fe9f909c6652b7caf4973465f4b3fc5d9647f219/Images/AfterTab.png -------------------------------------------------------------------------------- /Images/Example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulverteam/SoulverTextKit/fe9f909c6652b7caf4973465f4b3fc5d9647f219/Images/Example.gif -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Zac Cohan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SoulverTextKit", 8 | platforms: [ 9 | .macOS(.v10_14), 10 | .iOS(.v13), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "SoulverTextKit", 16 | targets: ["SoulverTextKit"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | .package(url: "https://github.com/soulverteam/SoulverCore", from: "1.2.1") 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 26 | .target( 27 | name: "SoulverTextKit", 28 | dependencies: ["SoulverCore"]), 29 | .testTarget( 30 | name: "SoulverTextKitTests", 31 | dependencies: ["SoulverTextKit"]), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SoulverTextKit 2 | 3 | ![Swift 5.3](https://img.shields.io/badge/Swift-5.3-blue.svg?style=flat) 4 | ![Platform](https://img.shields.io/badge/platform-macOS-lightgrey.svg?style=flat) 5 | ![Platform](https://img.shields.io/badge/platform-iOS-lightgrey.svg?style=flat) 6 | 7 | SoulverTextKit lets you add a line-by-line calculation feature to any NSTextView or UITextView. It uses [SoulverCore](https://soulver.app/core) for number crunching, which also provides unit conversions, date & times calculations, and more. 8 | 9 | 10 | 11 | ## Requirements 12 | 13 | - Xcode 11+ 14 | - Swift 5+ 15 | 16 | ## Supported Platforms 17 | 18 | - macOS 10.14.4+ 19 | - iOS/iPadOS 13+ 20 | 21 | ## Installation 22 | 23 | SoulverTextKit is distributed using the [Swift Package Manager](https://swift.org/package-manager). To install it into a project, simply add it as a dependency within your `Package.swift` manifest: 24 | 25 | ```swift 26 | let package = Package( 27 | ... 28 | dependencies: [ 29 | .package(url: "https://github.com/soulverteam/SoulverTextKit.git", from: "0.0.1") 30 | ], 31 | ... 32 | ) 33 | ``` 34 | 35 | [Or add the package in Xcode.](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app) 36 | 37 | ## Usage 38 | 39 | There are 3 steps to integrate SoulverTextKit in your project. Examples for both NSTextView & UITextView are provided in this repository. 40 | 41 | ### Step 1 42 | #### Import SoulverTextKit in your text view delegate 43 | 44 | ```swift 45 | import SoulverTextKit 46 | ``` 47 | ### Step 2 48 | #### Create an instance variable of ParagraphCalculator and initialize it with your TextView's NSTextStorage and NSTextContainer: 49 | 50 | ```swift 51 | 52 | @IBOutlet var textView: NSTextView! 53 | var paragraphCalculator: SoulverTextKit.ParagraphCalculator! 54 | 55 | override func viewDidLoad() { 56 | super.viewDidLoad() 57 | self.paragraphCalculator = ParagraphCalculator(answerPosition: .afterEquals, textStorage: self.textView.textStorage, textContainer: self.textView.textContainer) 58 | } 59 | 60 | ``` 61 | 62 | ### Step 3 63 | #### Implement NS/UITextView textDidChange and NSLayoutManager didChangeGeometry delegate methods 64 | 65 | ```swift 66 | 67 | func textDidChange(_ notification: Notification) { 68 | 69 | // Let us know when the text changes, so we can evaluate any changed lines if necessary 70 | paragraphCalculator.textDidChange() 71 | } 72 | 73 | func layoutManager(_ layoutManager: NSLayoutManager, textContainer: NSTextContainer, didChangeGeometryFrom oldSize: NSSize) { 74 | 75 | // Let us know when the text view changes size, so we can change update the formatting if necessary 76 | paragraphCalculator.layoutDidChange() 77 | } 78 | ``` 79 | 80 | ### Step 4 (optional) 81 | #### Prevent the user editing the result of a paragraph 82 | 83 | ```swift 84 | func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { 85 | 86 | // Check with us to see if the user should be able to edit parts of the paragraph. 87 | switch paragraphCalculator.shouldAllowReplacementFor(affectedCharRange: affectedCharRange, replacementString: replacementString) { 88 | case .allow: 89 | return true 90 | case .deny: 91 | NSSound.beep() 92 | return false 93 | case .setIntertionPoint(range: let range): 94 | textView.setSelectedRange(range) 95 | return false 96 | } 97 | 98 | } 99 | ``` 100 | 101 | 102 | ## Styles 103 | 104 | There are 3 built-in styles for calculation paragraphs: `afterTab`, `afterPipe` and `afterEquals`. Choose your preferred style when creating the `ParagraphCalculator`. 105 | 106 | #### After Tab 107 | 108 | 109 | 110 | #### After Pipe 111 | 112 | 113 | 114 | #### After Equals 115 | 116 | 117 | 118 | ## License 119 | 120 | Copyright (c) 2021 Zac Cohan. 121 | SoulverTextKit is distributed under the MIT License. 122 | The use of the [SoulverCore](https://soulver.app/core) math engine in commercial software requires a special license. You can also modify ParagraphCalculator to use another math engine like [Math.js](https://github.com/josdejong/mathjs) or [Expression](https://github.com/nicklockwood/Expression). -------------------------------------------------------------------------------- /Sources/SoulverTextKit/Helpers/StringByParagraphs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringByParagraphs.swift 3 | // SoulverTextKit 4 | // 5 | // Created by Zac Cohan on 15/10/18. 6 | // Copyright © 2018 Zac Cohan. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias ParagraphIndex = Int 12 | typealias CharacterIndex = Int 13 | 14 | /* A view on a string that lets you access paragraphs by index */ 15 | internal class StringByParagraphs: CustomDebugStringConvertible { 16 | 17 | internal enum ParagraphIndexGranularity { 18 | case wholeParagraph 19 | case contents //no newparagraph character 20 | } 21 | 22 | internal let contents: String 23 | 24 | fileprivate let cache: ParagraphCache 25 | 26 | internal init(contents: String) { 27 | 28 | self.contents = contents 29 | self.cache = ParagraphCache() 30 | 31 | self.loadMetrics() 32 | } 33 | 34 | 35 | internal var contentEnd: Int { 36 | return self.cache.paragraphs.last?.range.upperBound ?? 0 37 | } 38 | 39 | internal var paragraphCount: Int { 40 | return self.cache.paragraphs.count 41 | } 42 | 43 | internal func rangeOfParagraphContainingLocation(_ location: CharacterIndex, includeNewParagraph: Bool = false) -> NSRange { 44 | 45 | if location > self.contentEnd { 46 | assertionFailure("Bad paragraph range request") 47 | return .zero 48 | } 49 | 50 | if let foundParagraph = _binarySearchForParagraphContaining(location: location) { 51 | return includeNewParagraph ? foundParagraph.range : foundParagraph.contentsRange 52 | } 53 | 54 | assertionFailure("Could not find the paragraph at \(location)") 55 | return .zero 56 | } 57 | 58 | 59 | internal func contentsRangeOfParagraphContainingLocation(_ location: CharacterIndex) -> NSRange { 60 | return self.rangeOfParagraphContainingLocation(location, includeNewParagraph: false) 61 | } 62 | 63 | internal func contentsRangeOfParagraphAtIndex(_ paragraphIndex: ParagraphIndex) -> NSRange { 64 | return self.cache.getAtIndex(paragraphIndex).contentsRange 65 | } 66 | 67 | internal func rangeOfParagraphAtIndex(_ paragraphIndex: ParagraphIndex) -> NSRange { 68 | return self.cache.getAtIndex(paragraphIndex).range 69 | } 70 | 71 | internal func paragraphIndexesContainingRange(_ range: NSRange, ignoreFinalTouchedParagraph: Bool) -> Range { 72 | 73 | if (self.cache.paragraphs.isEmpty) { 74 | return 0..<0 75 | } 76 | let firstRange = rangeOfParagraphContainingLocation(range.location) 77 | 78 | // Handle the case where we've specified an entire paragraph range 79 | if firstRange == range { 80 | let paragraph = self.cache.getAtLocation(firstRange.location) 81 | 82 | //the case of |123\n| is technically both paragraph 0 and paragraph 1 83 | if paragraph.index != self.paragraphCount - 1 && !ignoreFinalTouchedParagraph { 84 | 85 | let nextParagraph = self.cache.getAtIndex(paragraph.index + 1) 86 | if NSIntersectionRange(range, paragraph.range).length == paragraph.range.length { 87 | return paragraph.index ..< nextParagraph.index + 1 88 | } 89 | } 90 | 91 | return paragraph.index ..< paragraph.index + 1 92 | } 93 | 94 | let lastRange = range.length > 0 95 | ? rangeOfParagraphContainingLocation(range.location + range.length) 96 | : firstRange 97 | 98 | let firstParagraph = self.cache.getAtLocation(firstRange.location) 99 | var lastParagraph = self.cache.getAtLocation(lastRange.location) 100 | 101 | if ignoreFinalTouchedParagraph && firstParagraph.index != lastParagraph.index && lastParagraph.index > 0 { 102 | 103 | /* Handle the case where we've specified the entire paragraph above and it's including the last paragraph when it really shouldn't */ 104 | /* ie: |123\n456\n|789 shouldn't touch paragraph 2 */ 105 | 106 | let paragraphBeforeLast = self.cache.getAtIndex(lastParagraph.index - 1) 107 | 108 | if paragraphBeforeLast.range.upperBound == range.upperBound { 109 | lastParagraph = paragraphBeforeLast 110 | } 111 | } 112 | 113 | return firstParagraph.index.. Int { 118 | 119 | if (self.cache.paragraphs.isEmpty) { 120 | return 0 121 | } 122 | let range = rangeOfParagraphContainingLocation(location) 123 | let paragraph = self.cache.getAtLocation(range.location) 124 | return paragraph.index 125 | } 126 | 127 | internal subscript(index: Int, granularity: ParagraphIndexGranularity) -> String { 128 | get { 129 | let paragraph = self.cache.getAtIndex(index) 130 | let range = granularity == .contents ? paragraph.contentsRange : paragraph.range 131 | return (self.contents as NSString).substring(with: range) 132 | } 133 | } 134 | 135 | // MARK: - Comparing with other Indexed Strings 136 | 137 | internal func indexesDifferingFrom(stringByParagraphs: StringByParagraphs) -> IndexSet { 138 | 139 | var differingIndexes = IndexSet() 140 | 141 | for paragraphIndex in 0 ..< self.paragraphCount { 142 | 143 | if paragraphIndex < stringByParagraphs.paragraphCount { 144 | if self[paragraphIndex, .contents] != stringByParagraphs[paragraphIndex, .contents] { 145 | differingIndexes.insert(paragraphIndex) 146 | } 147 | } 148 | else { 149 | // this index doesn't even exist in the other string 150 | differingIndexes.insert(paragraphIndex) 151 | } 152 | } 153 | 154 | return differingIndexes 155 | 156 | } 157 | 158 | 159 | // MARK: - Global to Local Range Conversion 160 | 161 | internal func globalRangeFor(localRange: NSRange, on paragraphIndex: ParagraphIndex) -> NSRange { 162 | 163 | let paragraphRange = self.rangeOfParagraphAtIndex(paragraphIndex) 164 | let globalLocation = paragraphRange.location + localRange.location 165 | return NSMakeRange(globalLocation, localRange.length) 166 | 167 | } 168 | 169 | internal func localRangeFor(globalRange: NSRange, on paragraphIndex: ParagraphIndex) -> NSRange? { 170 | 171 | let paragraphRange = self.rangeOfParagraphContainingLocation(globalRange.location) 172 | 173 | let startIndex = globalRange.location - paragraphRange.location 174 | var length = globalRange.length 175 | 176 | if length + startIndex > paragraphRange.upperBound { 177 | length = paragraphRange.upperBound - startIndex 178 | } 179 | 180 | return NSMakeRange(startIndex, length) 181 | 182 | } 183 | 184 | 185 | 186 | // MARK: - Loading Cache & Searching 187 | 188 | private func _binarySearchForParagraphContaining(location: CharacterIndex) -> ParagraphCache.Paragraph? { 189 | 190 | var lowerBound = 0 191 | var upperBound = self.paragraphCount 192 | 193 | while lowerBound < upperBound { 194 | let midIndex = lowerBound + (upperBound - lowerBound) / 2 195 | let midIndexParagraph = self.cache.paragraphs[midIndex] 196 | 197 | if midIndexParagraph.range.contains(location) || (midIndexParagraph.range.length == 0 && midIndexParagraph.range.lowerBound == location) { 198 | return midIndexParagraph 199 | } else if midIndexParagraph.range.location < location { 200 | lowerBound = midIndex + 1 201 | } else { 202 | upperBound = midIndex 203 | } 204 | } 205 | 206 | 207 | // At the end of the contents (i.e equal to contentEnd is fine, it's the last paragraph) 208 | if let lastParagraph = self.cache.paragraphs.last, lastParagraph.range.upperBound == location { 209 | return lastParagraph 210 | } 211 | 212 | return nil 213 | 214 | } 215 | 216 | private func loadMetrics() { 217 | 218 | let string = self.contents as NSString 219 | 220 | var location: Int = 0 221 | var lastContentsEnd: Int? 222 | var index: Int = 0 223 | let max = string.length 224 | while (location < max) { 225 | var paragraphStart: Int = 0 226 | var paragraphEnd: Int = 0 227 | var contentsEnd: Int = 0 228 | string.getParagraphStart(¶graphStart, end: ¶graphEnd, contentsEnd: &contentsEnd, 229 | for: NSMakeRange(location, 0)) 230 | let r = NSMakeRange(paragraphStart, paragraphEnd - paragraphStart) 231 | let contents = string.substring(with: r) 232 | 233 | let paragraph = ParagraphCache.Paragraph(index: index, range: r, 234 | contentsRange: NSMakeRange(paragraphStart, contentsEnd - paragraphStart), contents: contents) 235 | 236 | 237 | cache.addParagraph(paragraph) 238 | index += 1 239 | location = NSMaxRange(r) 240 | lastContentsEnd = contentsEnd 241 | } 242 | if (lastContentsEnd == nil || lastContentsEnd != location) { 243 | // Last paragraph ended with an end of paragraph character, add another empty paragraph to represent this 244 | let r = NSMakeRange(location, 0) 245 | let paragraph = ParagraphCache.Paragraph(index: index, range: r, contentsRange: r, contents: "") 246 | cache.addParagraph(paragraph) 247 | } 248 | 249 | } 250 | 251 | internal var debugDescription: String { 252 | 253 | var description = "\(type(of: self)) (\(Unmanaged.passUnretained(self).toOpaque())))\n" 254 | description += "'\(self.contents)'" 255 | 256 | return description 257 | 258 | } 259 | 260 | } 261 | 262 | 263 | fileprivate class ParagraphCache { 264 | 265 | class Paragraph { 266 | var index: Int 267 | 268 | /** Range of the entire paragraph, including end of paragraph character */ 269 | var range: NSRange 270 | 271 | /** Range of paragraph contents, not including end of paragraph character */ 272 | var contentsRange: NSRange 273 | 274 | var contents: String //includes new line character on the end \n 275 | 276 | init(index: Int, range: NSRange, contentsRange: NSRange, contents: String) { 277 | self.index = index 278 | self.range = range 279 | self.contentsRange = contentsRange 280 | self.contents = contents 281 | } 282 | 283 | 284 | } 285 | 286 | /** Paragraphs keyed by range location */ 287 | var paragraphsByLocation: [Int: Paragraph] = Dictionary() 288 | /** Paragraphs by index */ 289 | var paragraphs: [Paragraph] = Array() 290 | 291 | func getAtIndex(_ index: Int) -> Paragraph { 292 | 293 | if index < self.paragraphs.count { 294 | return paragraphs[index] 295 | } 296 | 297 | print ("Error: requesting paragraph range metrics for out of bounds paragraph index (\(index), paragraph count is \(self.paragraphs.count))") 298 | 299 | return Paragraph(index: 0, range: NSMakeRange(0, 0), contentsRange: NSMakeRange(0, 0), contents: "") 300 | 301 | 302 | } 303 | 304 | func getAtLocation(_ location: Int) -> Paragraph { 305 | return paragraphsByLocation[location]! 306 | } 307 | 308 | func count() -> Int { 309 | return paragraphs.count 310 | } 311 | 312 | func isEmpty() -> Bool { 313 | return paragraphs.isEmpty 314 | } 315 | 316 | func invalidate() { 317 | paragraphsByLocation.removeAll(keepingCapacity: true) 318 | paragraphs.removeAll(keepingCapacity: true) 319 | } 320 | 321 | func addParagraph(_ paragraph: Paragraph) { 322 | paragraphs.append(paragraph) 323 | paragraphsByLocation[paragraph.range.location] = paragraph 324 | } 325 | 326 | 327 | 328 | } 329 | 330 | -------------------------------------------------------------------------------- /Sources/SoulverTextKit/SoulverTextKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SoulverTextKit.swift 3 | // SoulverTextKit 4 | // 5 | // Created by Zac Cohan on 31/1/21. 6 | // 7 | 8 | 9 | #if canImport(UIKit) 10 | import UIKit 11 | #elseif canImport(AppKit) 12 | import AppKit 13 | #endif 14 | 15 | /// The Soulver number-crunching math engine that this package depends on 16 | import SoulverCore 17 | 18 | /// What makes a Soulver line a Soulver line in your text view? 19 | public enum AnswerPosition { 20 | 21 | /// right align answers after a tab 22 | case afterTab 23 | 24 | /// left align answers to a pipe character | positioned a standard distance from the right of the text view 25 | case afterPipe 26 | 27 | /// insert answers directly after = following the expression 28 | case afterEquals 29 | 30 | /// this string must be present for this line to be processed as a Soulver line 31 | var divider: String { 32 | switch self { 33 | case .afterTab: 34 | return "\t" 35 | case .afterPipe: 36 | return "\t| " 37 | case .afterEquals: 38 | return " = " 39 | } 40 | } 41 | 42 | /// when this character is typed, the line automatically becomes a Soulver line 43 | var trigger: String { 44 | switch self { 45 | case .afterTab: 46 | return "\t" 47 | case .afterPipe: 48 | return "|" 49 | case .afterEquals: 50 | return "=" 51 | } 52 | } 53 | 54 | } 55 | 56 | public enum TextReplacementDecision { 57 | case allow 58 | case deny 59 | case setIntertionPoint(range: NSRange) 60 | } 61 | 62 | 63 | /// Use this object to add Soulver-like calculation abilities to a standard NSTextView or UITextView 64 | public class ParagraphCalculator { 65 | 66 | private var stringByParagraphs: StringByParagraphs 67 | private let calculator = Calculator(customization: .standard) 68 | 69 | let answerPosition: AnswerPosition 70 | let textStorage: NSTextStorage 71 | let textContainer: NSTextContainer 72 | 73 | public init(answerPosition: AnswerPosition, textStorage: NSTextStorage, textContainer: NSTextContainer) { 74 | 75 | self.answerPosition = answerPosition 76 | self.textStorage = textStorage 77 | self.textContainer = textContainer 78 | 79 | self.stringByParagraphs = StringByParagraphs(contents: textStorage.string) 80 | 81 | } 82 | 83 | // MARK: - Please call the following methods (when appropriate) 84 | 85 | public func textDidChange() { 86 | 87 | // Hold onto the previous state before the text changed 88 | let previousStringByParagraphs = self.stringByParagraphs 89 | 90 | // Update to the new state of the textStorage 91 | self.stringByParagraphs = StringByParagraphs(contents: textStorage.string) 92 | 93 | // Determine which lines have been edited 94 | let editedLines = self.stringByParagraphs.indexesDifferingFrom(stringByParagraphs: previousStringByParagraphs) 95 | 96 | // And which of those lines are actually Soulver lines 97 | let editedSoulverLines = self.indexesOfSoulverLines.intersection(editedLines) 98 | 99 | // Re-evaluate those lines and reformat 100 | self.evaluateLinesAt(indexes: editedSoulverLines) 101 | 102 | // the tab stops need to be updated for certain answer position styles after editing 103 | self.reformatPargraphStyleAt(paragraphIndexes: editedSoulverLines) 104 | 105 | 106 | } 107 | 108 | public func layoutDidChange() { 109 | 110 | /// Updates the tab stop size to keep the results hugging the right side of the text container 111 | self.reformatPargraphStyleAt(paragraphIndexes: self.indexesOfSoulverLines) 112 | 113 | } 114 | 115 | public func shouldAllowReplacementFor(affectedCharRange: NSRange, replacementString: String?) -> TextReplacementDecision { 116 | 117 | if self.rangeIntersectsSoulverLine(range: affectedCharRange) { 118 | 119 | if let replacementString = replacementString, replacementString == "\n" { 120 | 121 | // You're allowed to insert a new line from position 0 on a line 122 | if self.stringByParagraphs.rangeOfParagraphContainingLocation(affectedCharRange.lowerBound).location == affectedCharRange.location { 123 | return .allow 124 | } 125 | 126 | // Manually insert a new line below, rather than breaking up the existing line 127 | if let newInsertionPoint = self.insertLine(belowLineContaining: affectedCharRange) { 128 | return .setIntertionPoint(range: newInsertionPoint) 129 | } 130 | 131 | } 132 | 133 | // No editing a result please 134 | else if self.rangeIsInsideResult(range: affectedCharRange) { 135 | return .deny 136 | } 137 | 138 | } 139 | else if replacementString == self.answerPosition.trigger { 140 | 141 | self.makeSoulverLineAt(lineIndex: self.stringByParagraphs.indexOfParagraphContainingLocation(affectedCharRange.location)) 142 | 143 | return .setIntertionPoint(range: NSMakeRange(affectedCharRange.lowerBound, 0)) 144 | 145 | } 146 | 147 | return .allow 148 | 149 | } 150 | 151 | // MARK: - Evaluation 152 | 153 | private func evaluateLinesAt(indexes: IndexSet) { 154 | 155 | guard indexes.isNotEmpty else { 156 | return 157 | } 158 | 159 | for lineIndex in indexes.reversed() { 160 | 161 | guard let expression = self.expressionOn(lineIndex: lineIndex), let resultRange = self.resultRangeOn(lineIndex: lineIndex) else { 162 | continue 163 | } 164 | 165 | let newResult = calculator.calculate(expression).stringValue 166 | 167 | if let oldResult = self.resultOn(lineIndex: lineIndex), oldResult == newResult { 168 | // these results are identical, skip updating the text storage 169 | continue 170 | } 171 | 172 | textStorage.replaceCharacters(in: resultRange, with: newResult) 173 | 174 | } 175 | 176 | // Update our indexed string with the new ranges 177 | self.stringByParagraphs = StringByParagraphs(contents: textStorage.string) 178 | 179 | } 180 | 181 | // MARK: - Formatting 182 | 183 | private var paragraphStyle: NSParagraphStyle { 184 | 185 | let paragraphStyle = NSMutableParagraphStyle() 186 | 187 | paragraphStyle.paragraphSpacing = 6.0 188 | 189 | switch self.answerPosition { 190 | case .afterTab: 191 | paragraphStyle.tabStops = [ 192 | NSTextTab(textAlignment: .right, location: self.textContainer.rightEdgeTabPoint, options: [:]), 193 | ] 194 | 195 | case .afterPipe: 196 | paragraphStyle.tabStops = [ 197 | NSTextTab(textAlignment: .left, location: self.textContainer.standardAnwswerColumnSizeTabPoint, options: [:]), 198 | ] 199 | case .afterEquals: 200 | break 201 | } 202 | 203 | return paragraphStyle 204 | 205 | } 206 | 207 | private func reformatPargraphStyleAt(paragraphIndexes: IndexSet) { 208 | 209 | guard paragraphIndexes.isNotEmpty else { 210 | return 211 | } 212 | 213 | let paragraphStyle = self.paragraphStyle 214 | 215 | for lineIndex in paragraphIndexes.reversed() { 216 | 217 | let lineRange = self.stringByParagraphs.rangeOfParagraphAtIndex(lineIndex) 218 | 219 | self.textStorage.addAttributes([.paragraphStyle : paragraphStyle], range: lineRange) 220 | 221 | } 222 | 223 | } 224 | 225 | 226 | 227 | // MARK: - Which lines in the text view are Soulver lines? 228 | 229 | private func isSoulverLineOn(lineIndex: LineIndex) -> Bool { 230 | 231 | let line = self.stringByParagraphs[lineIndex, .contents] 232 | return line.components(separatedBy: self.answerPosition.divider).count == 2 233 | 234 | } 235 | 236 | private var indexesOfSoulverLines: IndexSet { 237 | 238 | var indexSet = IndexSet() 239 | 240 | for lineIndex in (0 ..< stringByParagraphs.paragraphCount) { 241 | 242 | if self.isSoulverLineOn(lineIndex: lineIndex) { 243 | indexSet.insert(lineIndex) 244 | } 245 | 246 | } 247 | 248 | return indexSet 249 | 250 | } 251 | 252 | 253 | // MARK: - Inserting lines & making Soulver lines 254 | 255 | private func makeSoulverLineAt(lineIndex: LineIndex) { 256 | 257 | let rangeOfParagraph = self.stringByParagraphs.contentsRangeOfParagraphAtIndex(lineIndex) 258 | 259 | let attributes = self.textStorage.attributes(at: rangeOfParagraph.lowerBound, effectiveRange: nil) 260 | 261 | self.textStorage.insert(NSAttributedString(string: self.answerPosition.divider, attributes: attributes), at: rangeOfParagraph.upperBound) 262 | 263 | self.textDidChange() 264 | 265 | } 266 | 267 | /// Manually insert a new line below the line containing the given range 268 | /// - Returns: the new insertion point for the text view 269 | private func insertLine(belowLineContaining range: NSRange) -> NSRange? { 270 | 271 | if let lineIndex = IndexSet(self.stringByParagraphs.paragraphIndexesContainingRange(range, ignoreFinalTouchedParagraph: true)).last { 272 | 273 | let rangeOfParagraph = self.stringByParagraphs.contentsRangeOfParagraphAtIndex(lineIndex) 274 | 275 | let attributes = self.textStorage.attributes(at: rangeOfParagraph.lowerBound, effectiveRange: nil) 276 | 277 | self.textStorage.insert(NSAttributedString(string: "\n", attributes: attributes), at: rangeOfParagraph.upperBound) 278 | 279 | self.textDidChange() 280 | 281 | return self.stringByParagraphs.contentsRangeOfParagraphAtIndex(lineIndex + 1) 282 | } 283 | 284 | return nil 285 | 286 | } 287 | 288 | // MARK: - Utility (getting expressions, results and their ranges) 289 | 290 | private func rangeIntersectsSoulverLine(range: NSRange) -> Bool { 291 | 292 | // Which lines are included in this range? 293 | let affectedLines = IndexSet(self.stringByParagraphs.paragraphIndexesContainingRange(range, ignoreFinalTouchedParagraph: true)) 294 | 295 | if self.indexesOfSoulverLines.intersection(affectedLines).count > 0 { 296 | return true 297 | } 298 | 299 | return false 300 | 301 | } 302 | 303 | private func rangeIntersectsExpression(range: NSRange) -> Bool { 304 | 305 | // Which lines are included in this range? 306 | let affectedLines = self.stringByParagraphs.paragraphIndexesContainingRange(range, ignoreFinalTouchedParagraph: true) 307 | 308 | // If it's just one line, and the line is a Soulver line with a result 309 | if affectedLines.count == 1, let editingLineIndex = affectedLines.first, let expressionRange = self.expressionRangeOn(lineIndex: editingLineIndex) { 310 | 311 | // Check the result is not in the edited range 312 | let intersection = NSIntersectionRange(expressionRange, range) 313 | 314 | if intersection.location > 0 { 315 | return true 316 | } 317 | 318 | } 319 | 320 | return false 321 | } 322 | 323 | private func rangeIsInsideResult(range: NSRange) -> Bool { 324 | 325 | // Which lines are included in this range? 326 | let affectedLines = self.stringByParagraphs.paragraphIndexesContainingRange(range, ignoreFinalTouchedParagraph: true) 327 | 328 | // If it's just one line, and the line is a Soulver line with a result 329 | if affectedLines.count == 1, let editingLineIndex = affectedLines.first, let resultRange = self.resultRangeOn(lineIndex: editingLineIndex) { 330 | 331 | if range.location >= resultRange.location { 332 | 333 | // Check the result is not in the edited range 334 | let intersection = NSIntersectionRange(resultRange, range) 335 | 336 | if intersection.location > 0 { 337 | return true 338 | } 339 | 340 | } 341 | } 342 | 343 | return false 344 | 345 | } 346 | 347 | private func expressionOn(lineIndex: LineIndex) -> String? { 348 | 349 | let line = self.stringByParagraphs[lineIndex, .contents] 350 | return line.components(separatedBy: answerPosition.divider)[safe: 0] 351 | 352 | } 353 | 354 | private func resultOn(lineIndex: LineIndex) -> String? { 355 | 356 | let line = self.stringByParagraphs[lineIndex, .contents] 357 | return line.components(separatedBy: answerPosition.divider)[safe: 1] 358 | 359 | } 360 | 361 | 362 | private func expressionRangeOn(lineIndex: LineIndex) -> NSRange? { 363 | 364 | guard self.isSoulverLineOn(lineIndex: lineIndex) else { 365 | return nil 366 | } 367 | 368 | let line = self.stringByParagraphs[lineIndex, .contents] 369 | 370 | if let localExpressionRange = line.components(separatedBy: self.answerPosition.divider)[safe: 0]?.completeStringRange { 371 | 372 | let globalResultRange = self.stringByParagraphs.globalRangeFor(localRange: localExpressionRange, on: lineIndex) 373 | 374 | return globalResultRange 375 | } 376 | 377 | return nil 378 | 379 | } 380 | 381 | 382 | private func resultRangeOn(lineIndex: LineIndex) -> NSRange? { 383 | 384 | let line = self.stringByParagraphs[lineIndex, .contents] 385 | 386 | if let resultRange = line.components(separatedBy: self.answerPosition.divider)[safe: 1] { 387 | 388 | let localResultRange = NSMakeRange(line.count - resultRange.count, resultRange.count) 389 | let globalResultRange = self.stringByParagraphs.globalRangeFor(localRange: localResultRange, on: lineIndex) 390 | 391 | return globalResultRange 392 | } 393 | 394 | return nil 395 | 396 | } 397 | 398 | } 399 | 400 | 401 | private extension NSTextContainer { 402 | 403 | var rightEdgeTabPoint: CGFloat { 404 | return self.size.width - self.lineFragmentPadding * 2 405 | } 406 | 407 | var standardAnwswerColumnSizeTabPoint: CGFloat { 408 | return self.size.width - 200.0 409 | } 410 | } 411 | 412 | 413 | public extension Array where Element == String { 414 | 415 | func expressionStringFor(style: AnswerPosition) -> String { 416 | 417 | var placeholderString = "" 418 | 419 | for expression in self { 420 | 421 | if placeholderString.isNotEmpty { 422 | placeholderString.append("\n") 423 | } 424 | 425 | placeholderString.append(expression + style.divider) 426 | } 427 | 428 | return placeholderString 429 | 430 | } 431 | 432 | } 433 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SoulverTextKitTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SoulverTextKitTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SoulverTextKitTests/SoulverTextKitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SoulverTextKit 3 | 4 | final class SoulverTextKitTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(SoulverTextKit().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Tests/SoulverTextKitTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SoulverTextKitTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------