├── .editorconfig ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── config.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── Package.swift ├── README.md ├── Sources ├── STObjCLandShim │ ├── STObjCLandShim.m │ └── include │ │ ├── CGContextShim.h │ │ └── module.modulemap ├── STTextView │ └── module.swift ├── STTextViewAppKit │ ├── DragSelectedTextGestureRecognizer.swift │ ├── Extensions │ │ ├── NSEdgeInsets+orientation.swift │ │ ├── NSResponder+debug.swift │ │ ├── NSTextContentManager+Helpers.swift │ │ ├── NSTextLayoutManager+Helpers.swift │ │ ├── NSTextLayoutManager+InsertionPoint.swift │ │ └── NSView+Image.swift │ ├── Gutter │ │ ├── STGutterContainerView.swift │ │ ├── STGutterLineNumberCell.swift │ │ ├── STGutterMarker.swift │ │ ├── STGutterMarkerContainerView.swift │ │ ├── STGutterSeparatorView.swift │ │ └── STGutterView.swift │ ├── Logger.swift │ ├── Overlays │ │ ├── STContentView.swift │ │ ├── STLineHighlightView.swift │ │ ├── STSelectionHighlightView.swift │ │ └── STSelectionView.swift │ ├── Plugin │ │ ├── Plugin.swift │ │ ├── STPlugin.swift │ │ ├── STPluginContext.swift │ │ └── STPluginEvents.swift │ ├── STCompletion │ │ ├── STCompletionItem.swift │ │ ├── STCompletionViewController.swift │ │ ├── STCompletionViewControllerDelegate.swift │ │ ├── STCompletionViewControllerProtocol.swift │ │ └── Window │ │ │ ├── STCompletionWindow.swift │ │ │ └── STCompletionWindowController.swift │ ├── STInsertionPointIndicatorProtocol.swift │ ├── STInsertionPointView.swift │ ├── STTextAttachmentViewInteraction.swift │ ├── STTextContainer.swift │ ├── STTextFinderBarContainer.swift │ ├── STTextFinderClient.swift │ ├── STTextLayoutFragment.swift │ ├── STTextLayoutFragmentView.swift │ ├── STTextRenderView.swift │ ├── STTextView+Accessibility.swift │ ├── STTextView+Attachment.swift │ ├── STTextView+Capitalization.swift │ ├── STTextView+Complete.swift │ ├── STTextView+CopyPaste.swift │ ├── STTextView+Delete.swift │ ├── STTextView+DragGestureRecognizer.swift │ ├── STTextView+Find.swift │ ├── STTextView+FontPanel.swift │ ├── STTextView+Format.swift │ ├── STTextView+Gutter.swift │ ├── STTextView+Insert.swift │ ├── STTextView+InsertionPoint.swift │ ├── STTextView+Key.swift │ ├── STTextView+Mouse.swift │ ├── STTextView+NSColorChanging.swift │ ├── STTextView+NSDraggingDestination.swift │ ├── STTextView+NSDraggingSource.swift │ ├── STTextView+NSServicesMenuRequestor.swift │ ├── STTextView+NSTextCheckingClient.swift │ ├── STTextView+NSTextInputClient.swift │ ├── STTextView+NSTextLayoutManagerDelegate.swift │ ├── STTextView+NSTextLayoutOrientationProvider.swift │ ├── STTextView+NSTextViewportLayoutControllerDelegate.swift │ ├── STTextView+NSUserInterfaceValidations.swift │ ├── STTextView+Scrolling.swift │ ├── STTextView+Select.swift │ ├── STTextView+Speech.swift │ ├── STTextView+Undo.swift │ ├── STTextView+Yank.swift │ ├── STTextView.swift │ ├── STTextViewDelegate.swift │ ├── STTextViewDelegateProxy.swift │ ├── Utility │ │ └── NSColor+TextInsertionPoint.swift │ └── YankingManager.swift ├── STTextViewCommon │ ├── CoalescingUndoManager.swift │ ├── Extensions │ │ ├── Geometric+Helpers.swift │ │ ├── NSAttributedString+Helpers.swift │ │ ├── NSParagraphStyle+Helpers.swift │ │ ├── NSRange+Helpers.swift │ │ ├── NSTextLayoutManager+Selection.swift │ │ └── NStextLayoutFragment+isExtraLineFragment.swift │ ├── STAttributedTextElement.swift │ ├── STMarkedText.swift │ ├── STTextContentStorage.swift │ ├── STTextLayoutManager.swift │ ├── STTextViewProtocol.swift │ └── Utilities │ │ ├── ApproximateEquality.swift │ │ ├── LineHeight.swift │ │ ├── PixelAlign.swift │ │ ├── STRulerInsets.swift │ │ └── Throttler │ │ ├── Actor │ │ └── Throttler.swift │ │ ├── LICENSE │ │ ├── debounce.swift │ │ ├── delay.swift │ │ └── throttle.swift ├── STTextViewSwiftUI │ └── module.swift ├── STTextViewSwiftUIAppKit │ ├── TextView.swift │ └── TextViewModifier.swift ├── STTextViewSwiftUIUIKit │ ├── TextView.swift │ └── TextViewModifier.swift └── STTextViewUIKit │ ├── Gutter │ ├── STGutterLineNumberCell.swift │ ├── STGutterMarker.swift │ └── STGutterView.swift │ ├── Logger.swift │ ├── Overlays │ ├── STContentView.swift │ └── STLineHighlightView.swift │ ├── Plugin │ ├── Plugin.swift │ ├── STPlugin.swift │ ├── STPluginContext.swift │ └── STPluginEvents.swift │ ├── STTextInputTokenizer.swift │ ├── STTextLayoutFragment.swift │ ├── STTextLayoutFragmentView.swift │ ├── STTextLocation.swift │ ├── STTextLocationRange.swift │ ├── STTextSelectionRect.swift │ ├── STTextView+Gutter.swift │ ├── STTextView+NSTextLayoutManagerDelegate.swift │ ├── STTextView+NSTextViewportLayoutControllerDelegate.swift │ ├── STTextView+UIKeyInput.swift │ ├── STTextView+UIResponderStandardEditActions.swift │ ├── STTextView+UITextInput.swift │ ├── STTextView+UITextInputTraits.swift │ ├── STTextView+UITextInteractionDelegate.swift │ ├── STTextView+Undo.swift │ ├── STTextView.swift │ ├── STTextViewDelegate.swift │ ├── STTextViewDelegateProxy.swift │ └── UITextDirection+Conversion.swift ├── Tests ├── STTextViewAppKitTests │ ├── ContentTests.swift │ ├── Helpers │ │ └── NSEvent+Create.swift │ ├── TextSelectionNavigationTests.swift │ ├── TextViewTests.swift │ ├── TypingAttributesTests.swift │ └── UndoTests.swift └── STTextViewUIKitTests │ └── TextViewTests.swift ├── TextEdit.SwiftUI ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x@2x 1.png │ │ ├── icon_128x128@2x@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_256x256@2x@2x 1.png │ │ ├── icon_256x256@2x@2x.png │ │ ├── icon_32x32 1.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x@2x.png │ │ ├── icon_512x512@2x@2x 1.png │ │ └── icon_512x512@2x@2x.png │ └── Contents.json ├── ContentView.swift ├── Info.plist ├── LaunchScreen.storyboard ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── TextEdit.SwiftUI.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── TextEditUI.entitlements └── TextEditUIApp.swift ├── TextEdit.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── TextEdit ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x@2x 1.png │ │ ├── icon_128x128@2x@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_256x256@2x@2x 1.png │ │ ├── icon_256x256@2x@2x.png │ │ ├── icon_32x32 1.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x@2x.png │ │ ├── icon_512x512@2x@2x 1.png │ │ └── icon_512x512@2x@2x.png │ └── Contents.json ├── Mac │ ├── AppDelegate.swift │ ├── CompletionItem.swift │ ├── Tokenizer.swift │ ├── ViewController.swift │ └── en.lproj │ │ └── Main.storyboard ├── TextEdit.entitlements ├── TextEdit.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── TextEdit.xcscheme ├── content.txt └── iOS │ ├── AppDelegate.swift │ ├── Info.plist │ ├── LaunchScreen.storyboard │ ├── SceneDelegate.swift │ ├── ViewController.swift │ └── en.lproj │ └── Main.storyboard ├── cliff.toml └── mise.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | 5 | [*.swift] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [Makefile] 13 | indent_style = tab 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [krzyzanowskim] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Features, Bug Reports, Questions 4 | url: https://github.com/krzyzanowskim/STTextView/discussions/new?category=general 5 | about: Our preferred starting point if you have any questions or suggestions about configuration, features or behavior. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | Package.resolved 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 39 | # Packages/ 40 | # Package.pins 41 | # Package.resolved 42 | # *.xcodeproj 43 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 44 | # hence it is not needed unless you have added a package configuration file to your project 45 | # .swiftpm 46 | 47 | .build/ 48 | 49 | # CocoaPods 50 | # We recommend against adding the Pods directory to your .gitignore. However 51 | # you should judge for yourself, the pros and cons are mentioned at: 52 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 53 | # Pods/ 54 | # Add this line if you want to avoid checking in source code from the Xcode workspace 55 | # *.xcworkspace 56 | 57 | # Carthage 58 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 59 | # Carthage/Checkouts 60 | 61 | Carthage/Build/ 62 | 63 | # Accio dependency management 64 | Dependencies/ 65 | .accio/ 66 | 67 | # fastlane 68 | # It is recommended to not store the screenshots in the git repo. 69 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 70 | # For more information about the recommended setup visit: 71 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 72 | 73 | fastlane/report.xml 74 | fastlane/Preview.html 75 | fastlane/screenshots/**/*.png 76 | fastlane/test_output 77 | 78 | # Code Injection 79 | # After new code Injection tools there's a generated folder /iOSInjectionProject 80 | # https://github.com/johnno1962/injectionforxcode 81 | 82 | iOSInjectionProject/ 83 | 84 | ### Xcode ### 85 | 86 | ## Xcode 8 and earlier 87 | 88 | ### Xcode Patch ### 89 | *.xcodeproj/* 90 | !*.xcodeproj/project.pbxproj 91 | !*.xcodeproj/xcshareddata/ 92 | !*.xcworkspace/contents.xcworkspacedata 93 | /*.gcno 94 | **/xcshareddata/WorkspaceSettings.xcsettings 95 | 96 | # End of https://www.toptal.com/developers/gitignore/api/xcode,swift 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to STTextView 2 | 3 | Thanks for your interest in contributing to STTextView. 4 | 5 | Contributors must sign our [Contributor License Agreement](https://gist.githubusercontent.com/krzyzanowskim/97306283cb9bbea66ba277e8c76f0487/raw/e8f63873c46f3d84491fc2988afb2d057f0ad1ec/STTextView-CLA.md) before their contributions can be merged. 6 | 7 | ## Proposing changes 8 | 9 | The best way to propose a change is to [start a discussion on our GitHub repository](https://github.com/krzyzanowskim/STTextView/discussions). 10 | 11 | First, write a short **problem statement**, which _clearly_ and _briefly_ describes the problem you want to solve independently from any specific solution. It doesn't need to be long or formal, but it's difficult to consider a solution in absence of a clear understanding of the problem. 12 | 13 | Next, write a short **solution proposal**. How can the problem (or set of problems) you have stated above be addressed? What are the pros and cons of your approach? Again, keep it brief and informal. This isn't a specification, but rather a starting point for a conversation. 14 | 15 | By effectively engaging with the team and community early in your process, we're better positioned to give you feedback and understand your pull request once you open it. If the first thing we see from you is a big changeset, we're much less likely to respond to it in a timely manner. 16 | 17 | ## Tips to improve the chances of your PR getting reviewed and merged 18 | 19 | - Discuss your plans ahead of time with the team 20 | - Small, focused, incremental pull requests are much easier to review 21 | - Spend time explaining your changes in the pull request body 22 | - Add test coverage and documentation 23 | - Choose tasks that align with our roadmap 24 | - Pair with us and watch us code to learn the codebase 25 | - Low effort PRs, such as those that just re-arrange syntax, won't be merged without a compelling justification 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: changelog 2 | 3 | changelog: 4 | git-cliff -o CHANGELOG.md 5 | 6 | help: 7 | @echo "Available commands:" 8 | @echo " make changelog Generate CHANGELOG.md using git-cliff" 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "STTextView", 7 | platforms: [.macOS(.v12), .iOS(.v16), .macCatalyst(.v16)], 8 | products: [ 9 | .library( 10 | name: "STTextView", 11 | targets: ["STTextView", "STTextViewSwiftUI"] 12 | ) 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/krzyzanowskim/STTextKitPlus", from: "0.1.3"), 16 | .package(url: "https://github.com/krzyzanowskim/CoreTextSwift", from: "0.2.0") 17 | ], 18 | targets: [ 19 | .target( 20 | name: "STTextView", 21 | dependencies: [ 22 | .target(name: "STTextViewAppKit", condition: .when(platforms: [.macOS])), 23 | .target(name: "STTextViewUIKit", condition: .when(platforms: [.iOS, .macCatalyst])) 24 | ] 25 | ), 26 | .target( 27 | name: "STTextViewCommon", 28 | dependencies: [ 29 | .product(name: "STTextKitPlus", package: "STTextKitPlus") 30 | ] 31 | ), 32 | .target( 33 | name: "STTextViewAppKit", 34 | dependencies: [ 35 | .target(name: "STTextViewCommon"), 36 | .target(name: "STObjCLandShim", condition: .when(platforms: [.macOS])), 37 | .product(name: "STTextKitPlus", package: "STTextKitPlus"), 38 | .product(name: "CoreTextSwift", package: "CoreTextSwift") 39 | ] 40 | ), 41 | .target( 42 | name: "STTextViewUIKit", 43 | dependencies: [ 44 | .target(name: "STTextViewCommon"), 45 | .target(name: "STObjCLandShim", condition: .when(platforms: [.iOS, .macCatalyst])), 46 | .product(name: "STTextKitPlus", package: "STTextKitPlus"), 47 | .product(name: "CoreTextSwift", package: "CoreTextSwift") 48 | ], 49 | swiftSettings: [ 50 | // .define("USE_LAYERS_FOR_GLYPHS") 51 | ] 52 | ), 53 | .target( 54 | name: "STTextViewSwiftUI", 55 | dependencies: [ 56 | .target(name: "STTextViewSwiftUIAppKit", condition: .when(platforms: [.macOS])), 57 | .target(name: "STTextViewSwiftUIUIKit", condition: .when(platforms: [.iOS, .macCatalyst])) 58 | ] 59 | ), 60 | .target( 61 | name: "STTextViewSwiftUIAppKit", 62 | dependencies: [ 63 | .target(name: "STTextView") 64 | ] 65 | ), 66 | .target( 67 | name: "STTextViewSwiftUIUIKit", 68 | dependencies: [ 69 | .target(name: "STTextView") 70 | ] 71 | ), 72 | .target( 73 | name: "STObjCLandShim", 74 | publicHeadersPath: "include" 75 | ), 76 | .testTarget( 77 | name: "STTextViewAppKitTests", 78 | dependencies: [ 79 | .target(name: "STTextViewAppKit", condition: .when(platforms: [.macOS])) 80 | ] 81 | ), 82 | .testTarget( 83 | name: "STTextViewUIKitTests", 84 | dependencies: [ 85 | .target(name: "STTextViewUIKit", condition: .when(platforms: [.iOS, .macCatalyst])) 86 | ] 87 | ) 88 | ] 89 | ) 90 | -------------------------------------------------------------------------------- /Sources/STObjCLandShim/STObjCLandShim.m: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #if TARGET_OS_IPHONE 4 | #import 5 | #elif TARGET_OS_OSX 6 | #import 7 | #endif 8 | 9 | #import "CGContextShim.h" 10 | 11 | // https://github.com/gnachman/iTerm2/blob/b7d3fc6d9372a083ffadc2effbba01b67c040a69/sources/iTermGraphicsUtilities.m#L23 12 | 13 | // if (useThinStrokes) { 14 | // CGContextSetShouldSmoothFonts(ctx, YES); 15 | // // This seems to be available at least on 10.8 and later. The only reference to it is in 16 | // // WebKit. This causes text to render just a little lighter, which looks nicer. 17 | // savedFontSmoothingStyle = CGContextGetFontSmoothingStyle(ctx); 18 | // CGContextSetFontSmoothingStyle(ctx, 16); 19 | // } 20 | 21 | 22 | #ifdef USE_FONT_SMOOTHING_STYLE 23 | // The use of non-public or deprecated APIs is not permitted on the App Store 24 | 25 | extern void CGContextSetFontSmoothingStyle(CGContextRef, int); 26 | extern int CGContextGetFontSmoothingStyle(CGContextRef); 27 | 28 | void STContextSetFontSmoothingStyle(CGContextRef context, int style) { 29 | CGContextSetFontSmoothingStyle(context, style); 30 | } 31 | 32 | int STContextGetFontSmoothingStyle(CGContextRef context) { 33 | return CGContextGetFontSmoothingStyle(context); 34 | } 35 | #endif 36 | -------------------------------------------------------------------------------- /Sources/STObjCLandShim/include/CGContextShim.h: -------------------------------------------------------------------------------- 1 | #ifndef CGContextShim_h 2 | #define CGContextShim_h 3 | 4 | #include 5 | 6 | #if TARGET_OS_IPHONE 7 | #import 8 | #elif TARGET_OS_OSX 9 | #import 10 | #endif 11 | 12 | #ifdef USE_FONT_SMOOTHING_STYLE 13 | void STContextSetFontSmoothingStyle(CGContextRef context, int style); 14 | int STContextGetFontSmoothingStyle(CGContextRef context); 15 | #endif 16 | 17 | #endif /* CGContextShim_h */ 18 | -------------------------------------------------------------------------------- /Sources/STObjCLandShim/include/module.modulemap: -------------------------------------------------------------------------------- 1 | module STObjCLandShim { 2 | header "CGContextShim.h" 3 | } 4 | -------------------------------------------------------------------------------- /Sources/STTextView/module.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if os(macOS) 4 | @_exported import STTextViewAppKit 5 | #endif 6 | 7 | #if os(iOS) || targetEnvironment(macCatalyst) 8 | @_exported import STTextViewUIKit 9 | #endif 10 | 11 | @_exported import STTextViewCommon 12 | 13 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/DragSelectedTextGestureRecognizer.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | final class DragSelectedTextGestureRecognizer: NSPressGestureRecognizer { 7 | 8 | override func mouseDown(with event: NSEvent) { 9 | guard let textView = self.view as? STTextView else { 10 | return 11 | } 12 | 13 | if isEnabled { 14 | let eventPoint = textView.convert(event.locationInWindow, from: nil) 15 | if !interactionInSelectedRange(at: eventPoint) { 16 | self.state = .failed 17 | } 18 | } 19 | 20 | super.mouseDown(with: event) 21 | } 22 | 23 | private func interactionInSelectedRange(at location: CGPoint) -> Bool { 24 | guard let textView = self.view as? STTextView else { 25 | return false 26 | } 27 | 28 | let currentSelectionRanges = textView.textLayoutManager.textSelectionsRanges(.withoutInsertionPoints) 29 | if currentSelectionRanges.isEmpty { 30 | return false 31 | } 32 | 33 | return currentSelectionRanges.reduce(true) { partialResult, range in 34 | guard let interationLocation = textView.textLayoutManager.location(interactingAt: location, inContainerAt: range.location) else { 35 | return partialResult 36 | } 37 | return partialResult && range.contains(interationLocation) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Extensions/NSEdgeInsets+orientation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension NSEdgeInsets { 4 | var horizontalInsets: CGFloat { 5 | left + right 6 | } 7 | 8 | var verticalInsets: CGFloat { 9 | top + bottom 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Extensions/NSResponder+debug.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension NSResponder { 7 | var responderChain: [NSResponder] { 8 | Array(sequence(first: self, next: \.nextResponder)) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Extensions/NSTextContentManager+Helpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | import STTextKitPlus 6 | 7 | extension NSTextContentManager { 8 | 9 | /// Attributes at location 10 | func attributes(at location: NSTextLocation) -> [NSAttributedString.Key: Any] { 11 | guard !documentRange.isEmpty else { 12 | return [:] 13 | } 14 | 15 | let effectiveLocation: NSTextLocation 16 | if location == documentRange.location { 17 | effectiveLocation = location 18 | } else if location == documentRange.endLocation { 19 | effectiveLocation = self.location(location, offsetBy: -1) ?? location 20 | } else { 21 | effectiveLocation = location 22 | } 23 | 24 | // requires non-empty range 25 | return attributedString( 26 | in: NSTextRange( 27 | location: effectiveLocation, 28 | end: self.location(effectiveLocation, offsetBy: 1) 29 | ) 30 | )?.attributes( 31 | at: 0, 32 | effectiveRange: nil 33 | ) ?? [:] 34 | } 35 | 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Extensions/NSTextLayoutManager+Helpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | import STTextKitPlus 6 | 7 | extension NSTextLayoutManager { 8 | 9 | func enumerateSubstrings(in range: NSTextRange, options: String.EnumerationOptions = [], using block: (String?, NSTextRange, NSTextRange?, UnsafeMutablePointer) -> Void) { 10 | enumerateSubstrings(from: range.location, options: options) { substring, substringRange, enclosingRange, stop in 11 | let shouldContinue = substringRange.location <= range.endLocation 12 | if !shouldContinue { 13 | stop.pointee = true 14 | return 15 | } 16 | 17 | block(substring, substringRange, enclosingRange, stop) 18 | } 19 | } 20 | 21 | /// Enumerates rendering attributes in the range you provide. 22 | /// 23 | /// It enumerates only ranges with rendering attributes specified. 24 | /// This method only enumerates ranges with text that specify rendering attributes. Returning false from block breaks out of the enumeration. 25 | /// 26 | /// - Parameters: 27 | /// - range: The location at which to start the enumeration. 28 | /// - reverse: Whether to start the enumeration from the end of the range. 29 | /// - block: A closure you provide to determine if the enumeration finishes early. 30 | func enumerateRenderingAttributes(in range: NSTextRange, reverse: Bool, using block: (NSTextLayoutManager, [NSAttributedString.Key : Any], NSTextRange) -> Bool) { 31 | enumerateRenderingAttributes(from: range.location, reverse: reverse) { textLayoutManager, attributes, textRange in 32 | let shouldContinue = textRange.location <= range.endLocation 33 | if !shouldContinue { 34 | return false 35 | } 36 | 37 | return shouldContinue && block(textLayoutManager, attributes, textRange) 38 | } 39 | } 40 | 41 | /// Enumerates text layout frames for the range you provide. 42 | /// - Parameters: 43 | /// - range: The range as an NSTextRange. 44 | /// - block: A closure called for each rectangle in the range. One or more calls. 45 | @available(*, unavailable, message: "Work in progress") 46 | func enumerateTextLayoutFrames(in range: NSTextRange, using block: (_ frame: CGRect, _ frameRange: NSTextRange) -> Void) { 47 | enumerateTextLayoutFragments(in: range) { layoutFragment in 48 | for lineFragment in layoutFragment.textLineFragments { 49 | guard let lineTextRange = lineFragment.textRange(in: layoutFragment) else { 50 | continue 51 | } 52 | 53 | var x = lineFragment.typographicBounds.minX 54 | let y = layoutFragment.layoutFragmentFrame.minY + lineFragment.typographicBounds.minY 55 | var width = lineFragment.typographicBounds.width 56 | let height = lineFragment.typographicBounds.height 57 | var startLocation = lineTextRange.location 58 | var endLocation = lineTextRange.endLocation 59 | 60 | if lineTextRange.contains(range.location) { 61 | // cut off everything before location 62 | let leadingOffset = lineFragment.locationForCharacter(at: offset(from: lineTextRange.location, to: range.location)) 63 | x = x + leadingOffset.x 64 | width = width - leadingOffset.x 65 | 66 | if range.location > lineTextRange.location { 67 | startLocation = range.location 68 | } 69 | } 70 | 71 | if lineTextRange.contains(range.endLocation) { 72 | // cut off everything after endLocation 73 | let trailingOffset = lineFragment.locationForCharacter(at: offset(from: range.endLocation, to: lineTextRange.endLocation)) 74 | width = width - trailingOffset.x 75 | 76 | if range.endLocation < lineTextRange.endLocation { 77 | endLocation = range.endLocation 78 | } 79 | } 80 | 81 | if let frameTextRange = NSTextRange(location: startLocation, end: endLocation) { 82 | block(CGRect(x: x, y: y, width: width, height: height), frameTextRange) 83 | } 84 | 85 | } 86 | 87 | return true 88 | } 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Extensions/NSTextLayoutManager+InsertionPoint.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | // 5 | // Text selection is represented either 6 | // - by multiple NSTextSelection instances with a single or more range. 7 | // - by single NSTextSelection instance with multiple distinct ranges. All ranges in a text selection constitute a single insertion point. 8 | // 9 | // I don't have strong opinion whether one or another approach is better/worse. Both seems equally broken in TextKit 2 anyway (see STTextContentStorage workarounds) 10 | // As of today both are supported. Selections are processed in range order (asc/desc depending on the context) 11 | // 12 | // STTextView appends new insertion points as separate 13 | // 14 | 15 | import AppKit 16 | 17 | extension NSTextLayoutManager { 18 | 19 | /// Append insertion point. 20 | /// - Parameter point: A CGPoint that represents the location of the tap or click. 21 | internal func appendInsertionPointSelection(interactingAt point: CGPoint) { 22 | // Insertion points are either the selections with a single empty range 23 | // or single selection with multiple empty ranges. Both cases are handled. 24 | // I didn't find an advantage of one approach over the other 25 | textSelections += textSelectionNavigation.textSelections( 26 | interactingAt: point, 27 | inContainerAt: documentRange.location, 28 | anchors: [], 29 | modifiers: [], 30 | selecting: false, 31 | bounds: usageBoundsForTextContainer 32 | ) 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Extensions/NSView+Image.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSView { 4 | func stImage() -> NSImage? { 5 | guard let imageRep = bitmapImageRepForCachingDisplay(in: bounds) else { 6 | return nil 7 | } 8 | 9 | cacheDisplay(in: bounds, to: imageRep) 10 | 11 | guard let cgImage = imageRep.cgImage else { 12 | return nil 13 | } 14 | 15 | let img = NSImage(cgImage: cgImage, size: bounds.size) 16 | img.addRepresentation(imageRep) 17 | return img 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Gutter/STGutterContainerView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | // Gutter line number cells container 7 | final class STGutterContainerView: NSView { 8 | 9 | override init(frame: CGRect) { 10 | super.init(frame: frame) 11 | wantsLayer = true 12 | clipsToBounds = true 13 | } 14 | 15 | override var isFlipped: Bool { 16 | true 17 | } 18 | 19 | override var isOpaque: Bool { 20 | false 21 | } 22 | 23 | @available(*, unavailable) 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Gutter/STGutterLineNumberCell.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | import STTextViewCommon 6 | 7 | final class STGutterLineNumberCell: NSView { 8 | /// Line number 9 | let lineNumber: Int 10 | private let firstBaseline: CGFloat 11 | private let ctLine: CTLine 12 | let textSize: CGSize 13 | var insets: STRulerInsets = STRulerInsets() 14 | 15 | override func animation(forKey key: NSAnimatablePropertyKey) -> Any? { 16 | nil 17 | } 18 | 19 | override var debugDescription: String { 20 | "\(super.debugDescription) (number: \(lineNumber))" 21 | } 22 | 23 | override var firstBaselineOffsetFromTop: CGFloat { 24 | firstBaseline 25 | } 26 | 27 | init(firstBaseline: CGFloat, attributes: [NSAttributedString.Key: Any], number: Int) { 28 | self.lineNumber = number 29 | self.firstBaseline = firstBaseline 30 | 31 | let attributedString = NSAttributedString(string: "\(number)", attributes: attributes) 32 | self.ctLine = CTLineCreateWithAttributedString(attributedString) 33 | self.textSize = CGSize(width: ceil(CTLineGetTypographicBounds(ctLine, nil, nil, nil)), height: ctLine.height()) 34 | 35 | super.init(frame: .zero) 36 | wantsLayer = true 37 | clipsToBounds = true 38 | 39 | if ProcessInfo().environment["ST_LAYOUT_DEBUG"] == "YES" { 40 | layer?.backgroundColor = NSColor.systemOrange.withAlphaComponent(0.05).cgColor 41 | layer?.borderColor = NSColor.systemOrange.cgColor 42 | layer?.borderWidth = 0.5 43 | } 44 | } 45 | 46 | override var isFlipped: Bool { 47 | true 48 | } 49 | 50 | @available(*, unavailable) 51 | required init?(coder: NSCoder) { 52 | fatalError("init(coder:) has not been implemented") 53 | } 54 | 55 | override var intrinsicContentSize: NSSize { 56 | NSSize(width: textSize.width + insets.trailing + insets.leading, height: textSize.height) 57 | } 58 | 59 | override func draw(_ rect: CGRect) { 60 | super.draw(rect) 61 | 62 | guard let ctx = NSGraphicsContext.current?.cgContext else { 63 | return 64 | } 65 | 66 | ctx.saveGState() 67 | ctx.textMatrix = CGAffineTransform(scaleX: 1, y: isFlipped ? -1 : 1) 68 | 69 | // align to right 70 | ctx.textPosition = CGPoint(x: frame.width - (textSize.width + insets.trailing), y: firstBaseline) 71 | CTLineDraw(ctLine, ctx) 72 | ctx.restoreGState() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Gutter/STGutterMarker.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | import Cocoa 4 | 5 | public struct STGutterMarker: Equatable { 6 | /// Line number 7 | public let lineNumber: Int 8 | 9 | /// View 10 | public let view: NSView 11 | 12 | public init(lineNumber: Int, view: NSView) { 13 | self.lineNumber = lineNumber 14 | self.view = view 15 | } 16 | 17 | public init(lineNumber: Int) { 18 | self.view = MarkerView() 19 | self.lineNumber = lineNumber 20 | } 21 | } 22 | 23 | private class MarkerView: NSView { 24 | override init(frame frameRect: NSRect = .zero) { 25 | super.init(frame: frameRect) 26 | clipsToBounds = true 27 | } 28 | 29 | required init?(coder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | override var isFlipped: Bool { 34 | true 35 | } 36 | 37 | override func draw(_ dirtyRect: NSRect) { 38 | super.draw(dirtyRect) 39 | let bezierPath = indicatorPath(size: bounds.size) 40 | NSColor.controlAccentColor.withAlphaComponent(0.6).setFill() 41 | bezierPath.fill() 42 | } 43 | 44 | private func indicatorPath(size: CGSize, inset: CGFloat = 1) -> NSBezierPath { 45 | // Original dimensions from SVG 46 | let originalWidth: CGFloat = 83 47 | let originalHeight: CGFloat = 38 48 | 49 | // Calculate scale factors, accounting for inset 50 | let scaleX = (size.width - (inset * 2)) / originalWidth 51 | let scaleY = (size.height - (inset * 2)) / originalHeight 52 | 53 | let path = NSBezierPath() 54 | let height: CGFloat = originalHeight 55 | 56 | // Helper function to adjust Y coordinate if needed 57 | func y(_ value: CGFloat) -> CGFloat { 58 | return isFlipped ? height - value : value 59 | } 60 | 61 | // Start point and first curve 62 | path.move(to: NSPoint(x: 0, y: y(3))) 63 | path.curve(to: NSPoint(x: 2.97836, y: y(0)), 64 | controlPoint1: NSPoint(x: 0, y: y(1.34315)), 65 | controlPoint2: NSPoint(x: 1.3215, y: y(0))) 66 | 67 | // Line through middle points to 66,0 68 | path.line(to: NSPoint(x: 66, y: y(0))) 69 | 70 | // Right side curves 71 | path.curve(to: NSPoint(x: 73.5, y: y(3.5)), 72 | controlPoint1: NSPoint(x: 69, y: y(0)), 73 | controlPoint2: NSPoint(x: 70.8165, y: y(1.35322))) 74 | 75 | path.curve(to: NSPoint(x: 82.5, y: y(18.5)), 76 | controlPoint1: NSPoint(x: 76.1835, y: y(5.64678)), 77 | controlPoint2: NSPoint(x: 82.5, y: y(13.5))) 78 | 79 | path.curve(to: NSPoint(x: 73.5, y: y(34)), 80 | controlPoint1: NSPoint(x: 82.5, y: y(23.5)), 81 | controlPoint2: NSPoint(x: 75.5, y: y(32))) 82 | 83 | path.curve(to: NSPoint(x: 66, y: y(38)), 84 | controlPoint1: NSPoint(x: 71.5, y: y(36)), 85 | controlPoint2: NSPoint(x: 69, y: y(38))) 86 | 87 | // Line back through middle points 88 | path.line(to: NSPoint(x: 2.97836, y: y(38))) 89 | 90 | // Final curve to close the path 91 | path.curve(to: NSPoint(x: 0, y: y(35)), 92 | controlPoint1: NSPoint(x: 1.32151, y: y(38)), 93 | controlPoint2: NSPoint(x: 0, y: y(36.6569))) 94 | 95 | path.line(to: NSPoint(x: 0, y: y(3))) 96 | path.close() 97 | 98 | // Create transforms 99 | let scaleTransform = AffineTransform(scaleByX: scaleX, byY: scaleY) 100 | let translateTransform = AffineTransform(translationByX: inset, byY: inset) 101 | 102 | // Combine transforms 103 | var transform = AffineTransform.identity 104 | transform.append(scaleTransform) 105 | transform.append(translateTransform) 106 | 107 | // Apply the transform 108 | path.transform(using: transform) 109 | 110 | return path 111 | } 112 | 113 | 114 | } 115 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Gutter/STGutterMarkerContainerView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | // Gutter line number cells container 7 | final class STGutterMarkerContainerView: NSView { 8 | 9 | override init(frame: CGRect) { 10 | super.init(frame: frame) 11 | wantsLayer = true 12 | clipsToBounds = true 13 | } 14 | 15 | override var isFlipped: Bool { 16 | true 17 | } 18 | 19 | override var isOpaque: Bool { 20 | false 21 | } 22 | 23 | @available(*, unavailable) 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Gutter/STGutterSeparatorView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Cocoa 5 | 6 | class STGutterSeparatorView: NSView { 7 | @Invalidating(.display) 8 | var drawSeparator: Bool = true 9 | 10 | @Invalidating(.display) 11 | var separatorColor = NSColor.separatorColor.withAlphaComponent(0.1) 12 | 13 | override var isFlipped: Bool { 14 | true 15 | } 16 | 17 | override func draw(_ rect: CGRect) { 18 | super.draw(rect) 19 | 20 | guard let context = NSGraphicsContext.current?.cgContext else { 21 | return 22 | } 23 | 24 | if drawSeparator { 25 | context.setLineWidth(1) 26 | context.setStrokeColor(separatorColor.cgColor) 27 | context.addLines(between: [CGPoint(x: frame.width - 0.5, y: 0), CGPoint(x: frame.width - 0.5, y: bounds.maxY) ]) 28 | context.strokePath() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Logger.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import OSLog 6 | 7 | internal let logger = Logger(subsystem: "best.swift.sttextview", category: "STTextView") 8 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Overlays/STContentView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | final class STContentView: NSView { 7 | 8 | var backgroundColor: NSColor? { 9 | didSet { 10 | layer?.backgroundColor = backgroundColor?.cgColor 11 | } 12 | } 13 | 14 | override init(frame frameRect: NSRect) { 15 | super.init(frame: frameRect) 16 | wantsLayer = true 17 | clipsToBounds = true 18 | } 19 | 20 | required init?(coder: NSCoder) { 21 | super.init(coder: coder) 22 | wantsLayer = true 23 | clipsToBounds = true 24 | } 25 | 26 | override var isFlipped: Bool { 27 | #if os(macOS) 28 | true 29 | #else 30 | false 31 | #endif 32 | } 33 | 34 | override func viewDidChangeEffectiveAppearance() { 35 | super.viewDidChangeEffectiveAppearance() 36 | effectiveAppearance.performAsCurrentDrawingAppearance { [weak self] in 37 | guard let self else { return } 38 | self.backgroundColor = self.backgroundColor 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Overlays/STLineHighlightView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | final class STLineHighlightView: NSView { 7 | 8 | var backgroundColor: NSColor? { 9 | didSet { 10 | layer?.backgroundColor = backgroundColor?.cgColor 11 | } 12 | } 13 | 14 | override init(frame frameRect: NSRect) { 15 | super.init(frame: frameRect) 16 | wantsLayer = true 17 | clipsToBounds = true 18 | } 19 | 20 | required init?(coder: NSCoder) { 21 | super.init(coder: coder) 22 | wantsLayer = true 23 | clipsToBounds = true 24 | } 25 | 26 | override var isFlipped: Bool { 27 | #if os(macOS) 28 | true 29 | #else 30 | false 31 | #endif 32 | } 33 | 34 | override func viewDidChangeEffectiveAppearance() { 35 | super.viewDidChangeEffectiveAppearance() 36 | effectiveAppearance.performAsCurrentDrawingAppearance { [weak self] in 37 | guard let self else { return } 38 | self.backgroundColor = self.backgroundColor 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Overlays/STSelectionHighlightView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | final class STSelectionHighlightView: NSView { 7 | 8 | var backgroundColor: NSColor? = .selectedTextBackgroundColor { 9 | didSet { 10 | layer?.backgroundColor = backgroundColor?.cgColor 11 | } 12 | } 13 | 14 | override var isFlipped: Bool { 15 | #if os(macOS) 16 | true 17 | #else 18 | false 19 | #endif 20 | } 21 | 22 | override init(frame frameRect: NSRect) { 23 | super.init(frame: frameRect) 24 | wantsLayer = true 25 | clipsToBounds = true 26 | layer?.backgroundColor = backgroundColor?.cgColor 27 | } 28 | 29 | @available(*, unavailable) 30 | required init?(coder: NSCoder) { 31 | fatalError() 32 | } 33 | 34 | override func viewDidChangeEffectiveAppearance() { 35 | super.viewDidChangeEffectiveAppearance() 36 | effectiveAppearance.performAsCurrentDrawingAppearance { [weak self] in 37 | guard let self else { return } 38 | self.backgroundColor = self.backgroundColor 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Overlays/STSelectionView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | final class STSelectionView: NSView { 7 | 8 | var backgroundColor: NSColor? { 9 | didSet { 10 | layer?.backgroundColor = backgroundColor?.cgColor 11 | } 12 | } 13 | 14 | override init(frame frameRect: NSRect) { 15 | super.init(frame: frameRect) 16 | wantsLayer = true 17 | clipsToBounds = true 18 | } 19 | 20 | required init?(coder: NSCoder) { 21 | super.init(coder: coder) 22 | wantsLayer = true 23 | clipsToBounds = true 24 | } 25 | 26 | override var isFlipped: Bool { 27 | #if os(macOS) 28 | true 29 | #else 30 | false 31 | #endif 32 | } 33 | 34 | override func viewDidChangeEffectiveAppearance() { 35 | super.viewDidChangeEffectiveAppearance() 36 | effectiveAppearance.performAsCurrentDrawingAppearance { [weak self] in 37 | guard let self else { return } 38 | self.backgroundColor = self.backgroundColor 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Plugin/Plugin.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | internal struct Plugin { 5 | let instance: any STPlugin 6 | var events: STPluginEvents? 7 | 8 | /// Whether plugin is already setup 9 | var isSetup: Bool { 10 | events != nil 11 | } 12 | } 13 | 14 | internal extension Array { 15 | var events: [STPluginEvents] { 16 | compactMap(\.events) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Plugin/STPlugin.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | @MainActor 7 | public protocol STPlugin { 8 | associatedtype Coordinator = Void 9 | typealias Context = PluginContext 10 | typealias CoordinatorContext = STPluginCoordinatorContext 11 | 12 | /// Provides an opportunity to setup plugin environment 13 | func setUp(context: any Context) 14 | 15 | /// Creates an object to coordinate with the text view. 16 | func makeCoordinator(context: CoordinatorContext) -> Self.Coordinator 17 | 18 | /// Provides an opportunity to perform cleanup after plugin is about to remove. 19 | func tearDown() 20 | } 21 | 22 | public extension STPlugin { 23 | 24 | func tearDown() { 25 | // Nothing 26 | } 27 | } 28 | 29 | public extension STPlugin where Coordinator == Void { 30 | 31 | func makeCoordinator(context: CoordinatorContext) -> Coordinator { 32 | Coordinator() 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Plugin/STPluginContext.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | @MainActor 7 | public protocol PluginContext { 8 | associatedtype Plugin: STPlugin 9 | var coordinator: Plugin.Coordinator { get } 10 | var textView: STTextView { get } 11 | var events: STPluginEvents { get } 12 | } 13 | 14 | public struct STPluginContext: PluginContext { 15 | public let coordinator: Plugin.Coordinator 16 | public let textView: STTextView 17 | public let events: STPluginEvents 18 | 19 | init(coordinator: Plugin.Coordinator, textView: STTextView, events: STPluginEvents) { 20 | self.coordinator = coordinator 21 | self.textView = textView 22 | self.events = events 23 | } 24 | } 25 | 26 | public struct STPluginCoordinatorContext { 27 | public let textView: STTextView 28 | 29 | init(textView: STTextView) { 30 | self.textView = textView 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Plugin/STPluginEvents.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import AppKit 6 | 7 | public class STPluginEvents { 8 | 9 | var willChangeTextHandler: ((_ affectedRange: NSTextRange) -> Void)? 10 | var didChangeTextHandler: ((_ affectedRange: NSTextRange, _ replacementString: String?) -> Void)? 11 | var shouldChangeTextHandler: ((_ affectedCharRange: NSTextRange, _ replacementString: String?) -> Bool)? 12 | var onContextMenuHandler: ((_ location: NSTextLocation, _ contentManager: NSTextContentManager) -> NSMenu)? 13 | var didLayoutViewportHandler: ((_ visibleRange: NSTextRange?) -> Void)? 14 | 15 | @discardableResult 16 | public func onWillChangeText(_ handler: @escaping (_ affectedRange: NSTextRange) -> Void) -> Self { 17 | willChangeTextHandler = handler 18 | return self 19 | } 20 | 21 | @discardableResult 22 | public func onDidChangeText(_ handler: @escaping (_ affectedRange: NSTextRange, _ replacementString: String?) -> Void) -> Self { 23 | didChangeTextHandler = handler 24 | return self 25 | } 26 | 27 | 28 | @discardableResult 29 | public func shouldChangeText(_ handler: @escaping (_ affectedCharRange: NSTextRange, _ replacementString: String?) -> Bool) -> Self { 30 | shouldChangeTextHandler = handler 31 | return self 32 | } 33 | 34 | @discardableResult 35 | public func onContextMenu(_ handler: @escaping (_ location: NSTextLocation, _ contentManager: NSTextContentManager) -> NSMenu) -> Self { 36 | onContextMenuHandler = handler 37 | return self 38 | } 39 | 40 | @discardableResult 41 | public func onDidLayoutViewport(_ handler: @escaping (_ visibleRange: NSTextRange?) -> Void) -> Self { 42 | didLayoutViewportHandler = handler 43 | return self 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STCompletion/STCompletionItem.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import AppKit 6 | 7 | public protocol STCompletionItem: Identifiable { 8 | var view: NSView { get } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STCompletion/STCompletionViewControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | public protocol STCompletionViewControllerDelegate: AnyObject { 7 | func completionViewController(_ viewController: T, complete item: any STCompletionItem, movement: NSTextMovement) 8 | } 9 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STCompletion/STCompletionViewControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | public protocol STCompletionViewControllerProtocol: NSViewController { 7 | typealias Item = any STCompletionItem 8 | var items: [Item] { get set } 9 | var delegate: STCompletionViewControllerDelegate? { get set } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STCompletion/Window/STCompletionWindow.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | final class STCompletionWindow: NSWindow { 7 | 8 | override var canBecomeKey: Bool { 9 | // Disables keyboard events, but gives nice feeling where 10 | // tableview is not disabled, hence hacked in keyDown 11 | false 12 | } 13 | 14 | } 15 | 16 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STCompletion/Window/STCompletionWindowController.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | open class STCompletionWindowController: NSWindowController { 7 | 8 | public weak var delegate: STCompletionWindowDelegate? 9 | 10 | private var completionViewController: any STCompletionViewControllerProtocol { 11 | window!.contentViewController as! any STCompletionViewControllerProtocol 12 | } 13 | 14 | public var isVisible: Bool { 15 | window?.isVisible ?? false 16 | } 17 | 18 | public init(_ viewController: T) { 19 | let contentViewController = viewController 20 | 21 | let window = STCompletionWindow(contentViewController: contentViewController) 22 | window.setContentSize(CGSize(width: 450, height: 22 * 6.5)) 23 | window.contentMinSize = CGSize(width: 300, height: 50) 24 | window.styleMask = [.resizable, .fullSizeContentView] 25 | window.autorecalculatesKeyViewLoop = true 26 | window.level = .popUpMenu 27 | window.backgroundColor = .clear 28 | window.isExcludedFromWindowsMenu = true 29 | window.tabbingMode = .disallowed 30 | window.titleVisibility = .hidden 31 | window.titlebarAppearsTransparent = true 32 | window.isMovable = false 33 | window.standardWindowButton(.closeButton)?.isHidden = true 34 | window.standardWindowButton(.miniaturizeButton)?.isHidden = true 35 | window.standardWindowButton(.zoomButton)?.isHidden = true 36 | 37 | super.init(window: window) 38 | 39 | contentViewController.delegate = self 40 | } 41 | 42 | required public init?(coder: NSCoder) { 43 | fatalError("init(coder:) has not been implemented") 44 | } 45 | 46 | @available(*, unavailable) 47 | open override func showWindow(_ sender: Any?) { 48 | super.showWindow(sender) 49 | } 50 | 51 | public func show() { 52 | super.showWindow(nil) 53 | } 54 | 55 | public func showWindow(at origin: CGPoint, items: [any STCompletionItem], parent parentWindow: NSWindow) { 56 | guard let window = window else { return } 57 | 58 | if !isVisible { 59 | parentWindow.addChildWindow(window, ordered: .above) 60 | } 61 | 62 | completionViewController.items = items 63 | window.setFrameTopLeftPoint(origin) 64 | 65 | NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: window, queue: .main) { [weak self] notification in 66 | self?.cleanupOnClose() 67 | } 68 | 69 | NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: parentWindow, queue: .main) { [weak self] notification in 70 | self?.close() 71 | } 72 | } 73 | 74 | private func cleanupOnClose() { 75 | completionViewController.items.removeAll(keepingCapacity: true) 76 | } 77 | 78 | open override func close() { 79 | guard isVisible else { return } 80 | super.close() 81 | } 82 | } 83 | 84 | public protocol STCompletionWindowDelegate: AnyObject { 85 | func completionWindowController(_ windowController: STCompletionWindowController, complete item: any STCompletionItem, movement: NSTextMovement) 86 | } 87 | 88 | extension STCompletionWindowController: STCompletionViewControllerDelegate { 89 | public func completionViewController(_ viewController: T, complete item: any STCompletionItem, movement: NSTextMovement) { 90 | delegate?.completionWindowController(self, complete: item, movement: movement) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STInsertionPointIndicatorProtocol.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | /// Custom insertion point indicator view. Optional. 4 | public protocol STInsertionPointIndicatorProtocol: NSView { 5 | var insertionPointColor: NSColor { get set } 6 | 7 | func blinkStart() 8 | func blinkStop() 9 | } 10 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STInsertionPointView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import AppKit 6 | 7 | /// Wrapper for insertion point indicators. 8 | /// 9 | /// STInsertionPointView 10 | /// |---textInsertionIndicator (STInsertionPointIndicatorProtocol) 11 | /// 12 | internal class STInsertionPointView: NSView { 13 | private let textInsertionIndicator: any STInsertionPointIndicatorProtocol 14 | 15 | override var isFlipped: Bool { 16 | true 17 | } 18 | 19 | var insertionPointColor: NSColor { 20 | get { 21 | textInsertionIndicator.insertionPointColor 22 | } 23 | 24 | set { 25 | textInsertionIndicator.insertionPointColor = newValue 26 | } 27 | } 28 | 29 | required public init?(coder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | init(frame frameRect: NSRect, textInsertionIndicator: STInsertionPointIndicatorProtocol) { 34 | self.textInsertionIndicator = textInsertionIndicator 35 | super.init(frame: frameRect) 36 | 37 | addSubview(textInsertionIndicator) 38 | } 39 | 40 | override func setFrameSize(_ newSize: NSSize) { 41 | super.setFrameSize(newSize) 42 | // Manually reset size because `NSTextInsertionIndicator` 43 | // does not react to its autoresizingMask 44 | textInsertionIndicator.frame.size = newSize 45 | } 46 | 47 | func blinkStart() { 48 | textInsertionIndicator.blinkStart() 49 | } 50 | 51 | func blinkStop() { 52 | textInsertionIndicator.blinkStop() 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextAttachmentViewInteraction.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | import STTextViewCommon 6 | 7 | /// Protocol for attachment views to communicate with their containing text view 8 | @objc public protocol STTextAttachmentViewInteracting { 9 | /// Called when the attachment view is clicked or interacted with 10 | /// - Parameters: 11 | /// - attachment: The text attachment associated with this view 12 | /// - location: The location of the attachment in the text 13 | func attachmentViewDidReceiveInteraction(attachment: NSTextAttachment, at location: NSTextLocation) 14 | } 15 | 16 | /// Helper class to bridge attachment view interactions to text view delegates 17 | public class STTextAttachmentViewInteractionBridge: NSObject { 18 | weak var textView: STTextView? 19 | weak var attachment: NSTextAttachment? 20 | var location: NSTextLocation? 21 | 22 | init(textView: STTextView, attachment: NSTextAttachment, location: NSTextLocation) { 23 | self.textView = textView 24 | self.attachment = attachment 25 | self.location = location 26 | super.init() 27 | } 28 | 29 | @objc public func handleInteraction(_ sender: Any?) { 30 | guard let textView = textView, 31 | let attachment = attachment, 32 | let location = location else { 33 | return 34 | } 35 | 36 | // First, select the attachment in the text view 37 | textView.selectAttachment(at: location) 38 | 39 | // Then call the delegate method for attachment interaction 40 | if textView.delegateProxy.textView(textView, shouldAllowInteractionWith: attachment, at: location) { 41 | _ = textView.delegateProxy.textView(textView, clickedOnAttachment: attachment, at: location) 42 | } 43 | } 44 | } 45 | 46 | extension STTextView: STTextAttachmentViewInteracting { 47 | public func attachmentViewDidReceiveInteraction(attachment: NSTextAttachment, at location: NSTextLocation) { 48 | // First, select the attachment in the text view 49 | selectAttachment(at: location) 50 | 51 | // Then call the delegate method for attachment interaction 52 | if delegateProxy.textView(self, shouldAllowInteractionWith: attachment, at: location) { 53 | _ = delegateProxy.textView(self, clickedOnAttachment: attachment, at: location) 54 | } 55 | } 56 | } 57 | 58 | /// Extension to help configure attachment views for interaction 59 | extension NSView { 60 | 61 | /// Configures this view to send attachment interactions to the text view 62 | /// - Parameters: 63 | /// - textView: The text view containing this attachment 64 | /// - attachment: The attachment associated with this view 65 | /// - location: The location of the attachment in the text 66 | public func setupAttachmentInteraction(textView: STTextView, attachment: NSTextAttachment, location: NSTextLocation) { 67 | let bridge = STTextAttachmentViewInteractionBridge(textView: textView, attachment: attachment, location: location) 68 | 69 | // Store the bridge as associated object to keep it alive 70 | objc_setAssociatedObject(self, AssociatedKeys.interactionBridge, bridge, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 71 | 72 | // Configure based on view type 73 | if let button = self as? NSButton { 74 | button.target = bridge 75 | button.action = #selector(STTextAttachmentViewInteractionBridge.handleInteraction(_:)) 76 | } else if let control = self as? NSControl { 77 | control.target = bridge 78 | control.action = #selector(STTextAttachmentViewInteractionBridge.handleInteraction(_:)) 79 | } else { 80 | // For non-control views, add a gesture recognizer 81 | let tapGesture = NSClickGestureRecognizer(target: bridge, action: #selector(STTextAttachmentViewInteractionBridge.handleInteraction(_:))) 82 | self.addGestureRecognizer(tapGesture) 83 | 84 | // Store gesture recognizer to prevent it from being deallocated 85 | objc_setAssociatedObject(self, AssociatedKeys.tapGesture, tapGesture, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 86 | } 87 | } 88 | 89 | /// Returns the interaction bridge associated with this view, if any 90 | public var attachmentInteractionBridge: STTextAttachmentViewInteractionBridge? { 91 | return objc_getAssociatedObject(self, AssociatedKeys.interactionBridge) as? STTextAttachmentViewInteractionBridge 92 | } 93 | } 94 | 95 | private struct AssociatedKeys { 96 | static var interactionBridge = UnsafeMutablePointer.allocate(capacity: 1) 97 | static var tapGesture = UnsafeMutablePointer.allocate(capacity: 1) 98 | } -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextContainer.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | open class STTextContainer: NSTextContainer { 4 | // 5 | } 6 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextFinderBarContainer.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | final class STTextFinderBarContainer: NSObject, NSTextFinderBarContainer { 4 | 5 | // Forward NSTextFinderBarContainer to enclosing NSScrollView (for now at least) 6 | weak var client: STTextView? 7 | 8 | var findBarView: NSView? { 9 | get { 10 | client?.scrollView?.findBarView 11 | } 12 | 13 | set { 14 | client?.scrollView?.findBarView = newValue 15 | 16 | // Rearrange gutter view position in NSScrollView hierarchy 17 | Task { @MainActor in 18 | if let scrollView = client?.scrollView, let gutterView = client?.gutterView { 19 | gutterView.removeFromSuperviewWithoutNeedingDisplay() 20 | scrollView.addSubview(gutterView, positioned: .below, relativeTo: scrollView.findBarView) 21 | } 22 | } 23 | } 24 | } 25 | 26 | var isFindBarVisible: Bool { 27 | get { 28 | client?.scrollView?.isFindBarVisible ?? false 29 | } 30 | 31 | set { 32 | client?.scrollView?.isFindBarVisible = newValue 33 | } 34 | } 35 | 36 | func contentView() -> NSView? { 37 | client?.contentView 38 | } 39 | 40 | func findBarViewDidChangeHeight() { 41 | client?.scrollView?.findBarViewDidChangeHeight() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Capitalization.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView { 7 | 8 | open override func capitalizeWord(_ sender: Any?) { 9 | guard isEditable else { 10 | return 11 | } 12 | 13 | selectWord(sender) 14 | 15 | // capitalize attributed string without loosing attributes 16 | let selectionRanges = textLayoutManager.textSelectionsRanges(.withoutInsertionPoints) 17 | for selectionRange in selectionRanges { 18 | if let attributedString = textLayoutManager.textAttributedString(in: selectionRange) { 19 | replaceCharacters(in: selectionRange, with: attributedString.string.localizedCapitalized) 20 | } 21 | } 22 | 23 | // select updated ranges 24 | textLayoutManager.textSelections = selectionRanges.map { 25 | textLayoutManager.textSelectionNavigation.textSelection( 26 | for: .word, 27 | enclosing: NSTextSelection($0.location, affinity: .upstream) 28 | ) 29 | } 30 | 31 | } 32 | 33 | open override func lowercaseWord(_ sender: Any?) { 34 | guard isEditable else { 35 | return 36 | } 37 | 38 | selectWord(sender) 39 | 40 | // capitalize attributed string without loosing attributes 41 | let selectionRanges = textLayoutManager.textSelectionsRanges(.withoutInsertionPoints) 42 | for selectionRange in selectionRanges { 43 | if let attributedString = textLayoutManager.textAttributedString(in: selectionRange) { 44 | replaceCharacters(in: selectionRange, with: attributedString.string.localizedLowercase) 45 | } 46 | } 47 | 48 | // select updated ranges 49 | textLayoutManager.textSelections = selectionRanges.map { 50 | textLayoutManager.textSelectionNavigation.textSelection( 51 | for: .word, 52 | enclosing: NSTextSelection($0.location, affinity: .upstream) 53 | ) 54 | } 55 | } 56 | 57 | open override func uppercaseWord(_ sender: Any?) { 58 | guard isEditable else { 59 | return 60 | } 61 | 62 | selectWord(sender) 63 | 64 | // capitalize attributed string without loosing attributes 65 | let selectionRanges = textLayoutManager.textSelectionsRanges(.withoutInsertionPoints) 66 | for selectionRange in selectionRanges { 67 | if let attributedString = textLayoutManager.textAttributedString(in: selectionRange) { 68 | replaceCharacters(in: selectionRange, with: attributedString.string.localizedUppercase) 69 | } 70 | } 71 | 72 | // select updated ranges 73 | textLayoutManager.textSelections = selectionRanges.map { 74 | textLayoutManager.textSelectionNavigation.textSelection( 75 | for: .word, 76 | enclosing: NSTextSelection($0.location, affinity: .upstream) 77 | ) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Complete.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import AppKit 6 | 7 | extension STTextView { 8 | 9 | /// Supporting Autocomplete 10 | /// 11 | /// see ``NSStandardKeyBindingResponding`` 12 | @MainActor 13 | open override func complete(_ sender: Any?) { 14 | let didPerformCompletion = performSyncCompletion() 15 | if !didPerformCompletion { 16 | _completionTask?.cancel() 17 | _completionTask = Task(priority: .userInitiated) { [weak self] in 18 | guard let self else { return } 19 | if Task.isCancelled { 20 | return 21 | } 22 | let sessionId = UUID().uuidString 23 | logger.debug("async completion: \(sessionId)") 24 | let result = await performAsyncCompletion() 25 | logger.debug("async completion result: \(result) \(sessionId), cancelled: \(Task.isCancelled)") 26 | } 27 | } 28 | } 29 | 30 | /// Close completion window 31 | /// 32 | /// see ``complete(_:)`` 33 | @preconcurrency @MainActor 34 | @objc open func cancelComplete(_ sender: Any?) { 35 | _completionTask?.cancel() 36 | completionWindowController?.close() 37 | } 38 | 39 | @MainActor 40 | open override func cancelOperation(_ sender: Any?) { 41 | if let completionWindowController, completionWindowController.isVisible { 42 | cancelComplete(sender) 43 | } else { 44 | self.complete(sender) 45 | } 46 | } 47 | 48 | 49 | @MainActor @_unavailableFromAsync 50 | private func performSyncCompletion() -> Bool { 51 | dispatchPrecondition(condition: .onQueue(.main)) 52 | 53 | guard let insertionPointLocation = textLayoutManager.insertionPointLocations.first, 54 | let textCharacterSegmentRect = textLayoutManager.textSegmentFrame(at: insertionPointLocation, type: .standard), 55 | let completionItems = delegateProxy.textView(self, completionItemsAtLocation: insertionPointLocation) 56 | else { 57 | return false 58 | } 59 | 60 | if completionItems.isEmpty { 61 | cancelComplete(self) 62 | return false 63 | } 64 | 65 | if let window = self.window { 66 | // move left by arbitrary 14px 67 | let characterSegmentFrame = textCharacterSegmentRect.moved(dx: -14, dy: textCharacterSegmentRect.height) 68 | let completionWindowOrigin = window.convertPoint(toScreen: contentView.convert(characterSegmentFrame.origin, to: nil)) 69 | completionWindowController?.showWindow(at: completionWindowOrigin, items: completionItems, parent: window) 70 | completionWindowController?.delegate = self 71 | } 72 | 73 | return true 74 | } 75 | 76 | @MainActor @discardableResult 77 | private func performAsyncCompletion() async -> Bool { 78 | guard !Task.isCancelled, 79 | let insertionPointLocation = textLayoutManager.insertionPointLocations.first, 80 | let textCharacterSegmentRect = textLayoutManager.textSegmentFrame(at: insertionPointLocation, type: .standard) 81 | else { 82 | return false 83 | } 84 | 85 | if Task.isCancelled { 86 | return false 87 | } 88 | 89 | guard let completionItems = await delegateProxy.textView(self, completionItemsAtLocation: insertionPointLocation) else { 90 | return false 91 | } 92 | 93 | if Task.isCancelled { 94 | return false 95 | } 96 | 97 | if completionItems.isEmpty { 98 | cancelComplete(self) 99 | return false 100 | } 101 | 102 | if Task.isCancelled { 103 | return false 104 | } 105 | 106 | if let window = self.window { 107 | // move left by arbitrary 14px 108 | let characterSegmentFrame = textCharacterSegmentRect.moved(dx: -14, dy: textCharacterSegmentRect.height) 109 | let completionWindowOrigin = window.convertPoint(toScreen: contentView.convert(characterSegmentFrame.origin, to: nil)) 110 | completionWindowController?.showWindow(at: completionWindowOrigin, items: completionItems, parent: window) 111 | completionWindowController?.delegate = self 112 | } 113 | 114 | return true 115 | } 116 | } 117 | 118 | extension STTextView: STCompletionWindowDelegate { 119 | public func completionWindowController(_ windowController: STCompletionWindowController, complete item: any STCompletionItem, movement: NSTextMovement) { 120 | delegateProxy.textView(self, insertCompletionItem: item) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+CopyPaste.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | import UniformTypeIdentifiers 6 | 7 | extension STTextView { 8 | 9 | @objc open func copy(_ sender: Any?) { 10 | _ = writeSelection(to: NSPasteboard.general, types: [.rtf, .string]) 11 | } 12 | 13 | @objc open func paste(_ sender: Any?) { 14 | let pasteboard = NSPasteboard.general 15 | 16 | if pasteboard.canReadItem(withDataConformingToTypes: [UTType.rtf.identifier]) { 17 | pasteAsRichText(sender) 18 | } else if pasteboard.canReadItem(withDataConformingToTypes: [UTType.plainText.identifier]) { 19 | pasteAsPlainText(sender) 20 | } 21 | } 22 | 23 | @objc open func pasteAsPlainText(_ sender: Any?) { 24 | _ = readSelection(from: NSPasteboard.general, type: .string) 25 | } 26 | 27 | /// This action method inserts the contents of the pasteboard into the receiver’s text as rich text, maintaining its attributes. 28 | @objc open func pasteAsRichText(_ sender: Any?) { 29 | _ = readSelection(from: NSPasteboard.general, type: .rtf) 30 | } 31 | 32 | @objc open func cut(_ sender: Any?) { 33 | copy(sender) 34 | delete(sender) 35 | } 36 | 37 | @objc open func delete(_ sender: Any?) { 38 | for textRange in textLayoutManager.textSelections.flatMap(\.textRanges) { 39 | // "replaceContents" doesn't work with NSTextContentStorage at all 40 | // textLayoutManager.replaceContents(in: textRange, with: NSAttributedString()) 41 | let nsrange = NSRange(textRange, in: textContentManager) 42 | insertText("", replacementRange: nsrange) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Delete.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | 5 | import AppKit 6 | 7 | extension STTextView { 8 | 9 | open override func deleteForward(_ sender: Any?) { 10 | if let deletedString = delete(direction: .forward, destination: .character, allowsDecomposition: false) { 11 | _yankingManager.kill(action: .delete, string: deletedString) 12 | } 13 | } 14 | 15 | open override func deleteBackward(_ sender: Any?) { 16 | if let deletedString = delete(direction: .backward, destination: .character, allowsDecomposition: false) { 17 | _yankingManager.kill(action: .delete, string: deletedString) 18 | } 19 | } 20 | 21 | open override func deleteBackwardByDecomposingPreviousCharacter(_ sender: Any?) { 22 | if let deletedString = delete(direction: .backward, destination: .character, allowsDecomposition: true) { 23 | _yankingManager.kill(action: .delete, string: deletedString) 24 | } 25 | } 26 | 27 | open override func deleteWordBackward(_ sender: Any?) { 28 | if let deletedString = delete(direction: .backward, destination: .word, allowsDecomposition: false) { 29 | _yankingManager.kill(action: .deleteWordBackward, string: deletedString) 30 | } 31 | } 32 | 33 | open override func deleteWordForward(_ sender: Any?) { 34 | if let deletedString = delete(direction: .forward, destination: .word, allowsDecomposition: false) { 35 | _yankingManager.kill(action: .deleteWordForward, string: deletedString) 36 | } 37 | } 38 | 39 | open override func deleteToBeginningOfLine(_ sender: Any?) { 40 | if let deletedString = delete(direction: .backward, destination: .line, allowsDecomposition: false) { 41 | _yankingManager.kill(action: .deleteToBeginningOfLine, string: deletedString) 42 | } 43 | } 44 | 45 | open override func deleteToEndOfLine(_ sender: Any?) { 46 | if let deletedString = delete(direction: .forward, destination: .line, allowsDecomposition: false) { 47 | _yankingManager.kill(action: .deleteToEndOfLine, string: deletedString) 48 | } 49 | } 50 | 51 | open override func deleteToBeginningOfParagraph(_ sender: Any?) { 52 | if let deletedString = delete(direction: .backward, destination: .paragraph, allowsDecomposition: false) { 53 | _yankingManager.kill(action: .deleteToBeginningOfLine, string: deletedString) 54 | } 55 | } 56 | 57 | open override func deleteToEndOfParagraph(_ sender: Any?) { 58 | if let deletedString = delete(direction: .forward, destination: .paragraph, allowsDecomposition: false) { 59 | _yankingManager.kill(action: .deleteToEndOfParagraph, string: deletedString) 60 | } 61 | } 62 | 63 | @discardableResult 64 | private func delete(direction: NSTextSelectionNavigation.Direction, destination: NSTextSelectionNavigation.Destination, allowsDecomposition: Bool) -> String? { 65 | let textRanges = textLayoutManager.textSelections.flatMap { textSelection -> [NSTextRange] in 66 | if destination == .word { 67 | // FB9925766. deletionRanges only works correctly if textSelection is at the end of the word 68 | // Workaround 69 | return textLayoutManager.textSelectionNavigation.destinationSelection( 70 | for: textSelection, 71 | direction: direction, 72 | destination: destination, 73 | extending: true, 74 | confined: false 75 | )?.textRanges ?? [] 76 | } else { 77 | return textLayoutManager.textSelectionNavigation.deletionRanges( 78 | for: textSelection, 79 | direction: direction, 80 | destination: destination, 81 | allowsDecomposition: allowsDecomposition 82 | ) 83 | } 84 | } 85 | 86 | if textRanges.isEmpty || !shouldChangeText(in: textRanges, replacementString: "") { 87 | return nil 88 | } 89 | 90 | let deletedString = textRanges.reduce(into: "") { partialResult, textRange in 91 | partialResult += textLayoutManager.substring(in: textRange) 92 | } 93 | 94 | replaceCharacters(in: textRanges, with: "", useTypingAttributes: false, allowsTypingCoalescing: true) 95 | return deletedString 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+DragGestureRecognizer.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView { 7 | 8 | /// Gesture action for press and drag selected 9 | @objc func _dragSelectedTextGestureRecognizer(gestureRecognizer: NSGestureRecognizer) { 10 | let currentSelectionRanges = textLayoutManager.textSelectionsRanges(.withoutInsertionPoints) 11 | 12 | guard !currentSelectionRanges.isEmpty else { 13 | return 14 | } 15 | 16 | // TODO: loop over all selected ranges 17 | guard let selectionsAttributedString = textLayoutManager.textSelectionsAttributedString(), 18 | let textRange = currentSelectionRanges.first else { 19 | return 20 | } 21 | 22 | let rangeView = STTextRenderView(textLayoutManager: textLayoutManager, textRange: textRange) 23 | let draggingImage = rangeView.image() 24 | 25 | let draggingFrame = gestureRecognizer.view?.convert(rangeView.frame, from: contentView) ?? rangeView.frame 26 | 27 | let draggingItem = NSDraggingItem(pasteboardWriter: selectionsAttributedString) 28 | draggingItem.setDraggingFrame(draggingFrame, contents: draggingImage) 29 | 30 | draggingSession = beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Find.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView { 7 | 8 | /// Performs a find panel action specified by the sender's tag. 9 | /// 10 | /// This is the generic action method for the find menu and find panel, and can be overridden to implement a custom find panel. 11 | /// See NSTextFinder.Action for list of possible tags 12 | @objc open func performFindPanelAction(_ sender: Any?) { 13 | performTextFinderAction(sender) 14 | } 15 | 16 | /// Performs all find oriented actions. 17 | /// Before OS X v10.7, the default action for these menu items was performFindPanelAction(_:) 18 | @objc open override func performTextFinderAction(_ sender: Any?) { 19 | guard let menuItem = sender as? NSMenuItem, 20 | let action = NSTextFinder.Action(rawValue: menuItem.tag) 21 | else { 22 | assertionFailure("Unexpected caller") 23 | return 24 | } 25 | 26 | if textFinder.validateAction(action) { 27 | textFinder.performAction(action) 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+FontPanel.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | import STTextViewCommon 6 | 7 | extension STTextView { 8 | 9 | /// This action method changes the font of the selection for a rich text object, or of all text for a plain text object. 10 | @objc open func changeFont(_ sender: Any?) { 11 | guard isEditable, usesFontPanel, let fontManager = sender as? NSFontManager else { 12 | return 13 | } 14 | 15 | if let currentTypingFont = typingAttributes[.font] as? NSFont { 16 | let newFont = fontManager.convert(currentTypingFont) 17 | if !textLayoutManager.insertionPointLocations.isEmpty { 18 | typingAttributes[.font] = newFont 19 | } 20 | } 21 | 22 | // Assumption: self.attributedString map 1:1 with the storage range. May or may not be true all the time (I can imagine it won't) 23 | for textRange in textLayoutManager.textSelections.flatMap(\.textRanges) where !textRange.isEmpty { 24 | guard let attributedStringInRange = textContentManager.attributedString(in: textRange) else { 25 | return 26 | } 27 | 28 | let selectionNSRange = NSRange(textRange, in: textContentManager) 29 | for (runRange, value) in attributedStringInRange.attribute(.font, in: attributedStringInRange.range, options: .longestEffectiveRangeNotRequired) { 30 | guard let currentFont = value as? NSFont else { 31 | return 32 | } 33 | 34 | let documentScopeRange = NSRange(location: selectionNSRange.location + runRange.location, length: runRange.length) 35 | guard let runTextRange = NSTextRange(documentScopeRange, in: textContentManager) else { 36 | return 37 | } 38 | 39 | let newFont = fontManager.convert(currentFont) 40 | addAttributes([.font: newFont], range: runTextRange) 41 | } 42 | } 43 | } 44 | 45 | @objc open func validModesForFontPanel(_ fontPanel: NSFontPanel) -> NSFontPanel.ModeMask { 46 | [.collection, .face, .size] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Format.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import AppKit 6 | 7 | extension STTextView { 8 | 9 | /// Adds the underline attribute to the selected text attributes if absent; removes the attribute if present. 10 | /// 11 | /// If there is a selection and the first character of the selected range has any form of underline on it, 12 | /// or if there is no selection and the typing attributes have any form of underline, then underline is removed; 13 | /// otherwise a single simple underline is added. 14 | @objc open func underline(_ sender: Any?) { 15 | guard isEditable else { 16 | return 17 | } 18 | 19 | let selectionRanges = textLayoutManager.textSelections.flatMap(\.textRanges).filter({ !$0.isEmpty }) 20 | 21 | // If there is a selection and 22 | // the first character of the selected range 23 | // has any form of underline on it 24 | // then underline is removed 25 | if let location = selectionRanges.first?.location, 26 | textContentManager.attributes(at: location).contains(where: { $0.key == .underlineStyle }) 27 | { 28 | for textRange in selectionRanges { 29 | removeAttribute(.underlineStyle, range: textRange) 30 | removeAttribute(.underlineColor, range: textRange) 31 | } 32 | } else if selectionRanges.isEmpty, typingAttributes.contains(where: { $0.key == .underlineStyle }) { 33 | // or if there is no selection and the typing attributes have any form of underline 34 | for textRange in selectionRanges { 35 | removeAttribute(.underlineStyle, range: textRange) 36 | removeAttribute(.underlineColor, range: textRange) 37 | } 38 | } else { 39 | // otherwise a single simple underline is added 40 | for textRange in selectionRanges { 41 | addAttributes([.underlineStyle: NSUnderlineStyle.single.rawValue], range: textRange) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Insert.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView { 7 | 8 | open override func insertLineBreak(_ sender: Any?) { 9 | guard let scalar = Unicode.Scalar(NSLineSeparatorCharacter) else { 10 | assertionFailure() 11 | return 12 | } 13 | 14 | insertText(String(Character(scalar)), replacementRange: .notFound) 15 | } 16 | 17 | open override func insertTab(_ sender: Any?) { 18 | guard let scalar = Unicode.Scalar(NSTabCharacter) else { 19 | assertionFailure() 20 | return 21 | } 22 | insertText(String(Character(scalar)), replacementRange: .notFound) 23 | } 24 | 25 | open override func insertBacktab(_ sender: Any?) { 26 | guard let scalar = Unicode.Scalar(NSBackTabCharacter) else { 27 | assertionFailure() 28 | return 29 | } 30 | insertText(String(Character(scalar)), replacementRange: .notFound) 31 | } 32 | 33 | open override func insertTabIgnoringFieldEditor(_ sender: Any?) { 34 | insertTab(sender) 35 | } 36 | 37 | open override func insertParagraphSeparator(_ sender: Any?) { 38 | guard let scalar = Unicode.Scalar(NSParagraphSeparatorCharacter) else { 39 | assertionFailure() 40 | return 41 | } 42 | insertText(String(Character(scalar)), replacementRange: .notFound) 43 | } 44 | 45 | open override func insertNewline(_ sender: Any?) { 46 | guard let scalar = Unicode.Scalar(NSNewlineCharacter) else { 47 | assertionFailure() 48 | return 49 | } 50 | // insert newline with current typing attributes 51 | breakUndoCoalescing() 52 | insertText(String(Character(scalar))) 53 | breakUndoCoalescing() 54 | } 55 | 56 | open override func insertNewlineIgnoringFieldEditor(_ sender: Any?) { 57 | insertNewline(sender) 58 | } 59 | 60 | open override func insertText(_ insertString: Any) { 61 | insertText(insertString, replacementRange: .notFound) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Key.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView { 7 | open override func keyDown(with event: NSEvent) { 8 | 9 | guard isEditable else { 10 | super.keyDown(with: event) 11 | return 12 | } 13 | 14 | processingKeyEvent = true 15 | defer { 16 | processingKeyEvent = false 17 | } 18 | 19 | NSCursor.setHiddenUntilMouseMoves(true) 20 | 21 | if inputContext?.handleEvent(event) == false { 22 | interpretKeyEvents([event]) 23 | } 24 | } 25 | 26 | open override func performKeyEquivalent(with event: NSEvent) -> Bool { 27 | guard isEditable else { 28 | return super.performKeyEquivalent(with: event) 29 | } 30 | 31 | processingKeyEvent = true 32 | defer { 33 | processingKeyEvent = false 34 | } 35 | 36 | // ^Space -> complete: 37 | if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .control && event.charactersIgnoringModifiers == " " { 38 | doCommand(by: #selector(NSStandardKeyBindingResponding.complete(_:))) 39 | return true 40 | } 41 | 42 | return super.performKeyEquivalent(with: event) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+NSColorChanging.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView: NSColorChanging { 7 | 8 | public func changeColor(_ colorPanel: NSColorPanel?) { 9 | guard isEditable, let colorPanel = colorPanel else { 10 | return 11 | } 12 | 13 | if !textLayoutManager.insertionPointLocations.isEmpty { 14 | typingAttributes[.foregroundColor] = colorPanel.color 15 | } else { 16 | for textRange in textLayoutManager.textSelections.flatMap(\.textRanges) where !textRange.isEmpty { 17 | addAttributes([.foregroundColor: colorPanel.color], range: textRange) 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+NSDraggingDestination.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | /// A set of methods that the destination object (or recipient) of a dragged image must implement. 7 | /// 8 | /// NSView conforms to NSDraggingDestination 9 | extension STTextView { 10 | 11 | open override func concludeDragOperation(_ sender: NSDraggingInfo?) { 12 | super.concludeDragOperation(sender) 13 | cleanUpAfterDragOperation() 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+NSDraggingSource.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView: NSDraggingSource { 7 | 8 | public func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { 9 | context == .outsideApplication ? .copy : .move 10 | } 11 | 12 | public func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { 13 | cleanUpAfterDragOperation() 14 | self.draggingSession = nil 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+NSTextLayoutManagerDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView: NSTextLayoutManagerDelegate { 7 | 8 | public func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement) -> NSTextLayoutFragment { 9 | let textLayoutFragment = STTextLayoutFragment( 10 | textElement: textElement, 11 | range: textElement.elementRange, 12 | paragraphStyle: _defaultTypingAttributes[.paragraphStyle] as? NSParagraphStyle ?? .default 13 | ) 14 | return textLayoutFragment 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+NSTextLayoutOrientationProvider.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension STTextView: NSTextLayoutOrientationProvider { 7 | public var layoutOrientation: NSLayoutManager.TextLayoutOrientation { 8 | switch textLayoutManager.textLayoutOrientation(at: textLayoutManager.documentRange.location) { 9 | case .horizontal: 10 | return .horizontal 11 | case .vertical: 12 | return .vertical 13 | @unknown default: 14 | return textContainer.layoutOrientation 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+NSUserInterfaceValidations.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | import UniformTypeIdentifiers 6 | 7 | extension STTextView: NSMenuItemValidation { 8 | 9 | public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { 10 | validateUserInterfaceItem(menuItem) 11 | } 12 | 13 | } 14 | 15 | extension STTextView: NSUserInterfaceValidations { 16 | 17 | public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { 18 | switch item.action { 19 | case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)): 20 | return !textLayoutManager.documentRange.isEmpty && !selectedRange().isEmpty 21 | case #selector(selectAll(_:)): 22 | return !textLayoutManager.documentRange.isEmpty 23 | case #selector(paste(_:)), #selector(pasteAsPlainText(_:)), #selector(pasteAsRichText(_:)): 24 | return isEditable && NSPasteboard.general.string(forType: .string) != nil 25 | case #selector(undo(_:)): 26 | let result = allowsUndo ? undoManager?.canUndo ?? false : false 27 | 28 | // NSWindow does that like this, here (as debugged) 29 | if let undoManager = undoManager { 30 | (item as? NSMenuItem)?.title = undoManager.undoMenuItemTitle 31 | } 32 | 33 | return result 34 | case #selector(redo(_:)): 35 | let result = allowsUndo ? undoManager?.canRedo ?? false : false 36 | 37 | // NSWindow does that like this, here (as debugged) 38 | if let undoManager = undoManager { 39 | (item as? NSMenuItem)?.title = undoManager.redoMenuItemTitle 40 | } 41 | return result 42 | case #selector(performFindPanelAction(_:)), #selector(performTextFinderAction(_:)): 43 | return textFinder.validateAction(NSTextFinder.Action(rawValue: item.tag)!) 44 | case #selector(stopSpeaking(_:)): 45 | return speechSynthesizer.isSpeaking 46 | case #selector(startSpeaking(_:)): 47 | return !textLayoutManager.documentRange.isEmpty 48 | case #selector(toggleRuler(_:)): 49 | return true 50 | case #selector(toggleContinuousSpellChecking(_:)): 51 | (item as? NSMenuItem)?.state = spellCheckingType == .yes ? .on : .off 52 | return isEditable 53 | case #selector(toggleGrammarChecking(_:)): 54 | (item as? NSMenuItem)?.state = grammarCheckingType == .yes ? .on : .off 55 | return isEditable 56 | case #selector(toggleAutomaticTextCompletion(_:)): 57 | (item as? NSMenuItem)?.state = textCompletionType == .yes ? .on : .off 58 | return isEditable 59 | case #selector(toggleAutomaticSpellingCorrection(_:)): 60 | (item as? NSMenuItem)?.state = autocorrectionType == .yes ? .on : .off 61 | return isEditable 62 | default: 63 | return true 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Scrolling.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | 5 | import AppKit 6 | 7 | extension STTextView { 8 | 9 | open override func scroll(_ point: NSPoint) { 10 | contentView.scroll(point.applying(.init(translationX: -(gutterView?.frame.width ?? 0), y: 0))) 11 | } 12 | 13 | @discardableResult 14 | internal func scrollToVisible(_ selectionTextRange: NSTextRange, type: NSTextLayoutManager.SegmentType) -> Bool { 15 | guard var rect = textLayoutManager.textSegmentFrame(in: selectionTextRange, type: type) else { 16 | return false 17 | } 18 | 19 | if rect.width.isZero { 20 | // add padding around the point to ensure the visibility the segment 21 | // since the width of the segment is 0 for a selection 22 | rect = rect.inset(by: .init(top: 0, left: -textContainer.lineFragmentPadding, bottom: 0, right: -textContainer.lineFragmentPadding)) 23 | } 24 | 25 | // scroll to visible IN clip view (ignoring gutter view overlay) 26 | // adjust rect to mimick it's size to include gutter overlay 27 | rect.origin.x -= gutterView?.frame.width ?? 0 28 | rect.size.width += gutterView?.frame.width ?? 0 29 | return contentView.scrollToVisible(rect) 30 | } 31 | 32 | open override func centerSelectionInVisibleArea(_ sender: Any?) { 33 | guard let selectionTextRange = textLayoutManager.textSelections.last?.textRanges.last, 34 | var rect = textLayoutManager.textSegmentFrame(in: selectionTextRange, type: .selection) else { 35 | return 36 | } 37 | 38 | if rect.width.isZero { 39 | // add padding around the point to ensure the visibility the segment 40 | // since the width of the segment is 0 for a selection 41 | rect = rect.inset(by: .init(top: 0, left: -textContainer.lineFragmentPadding, bottom: 0, right: -textContainer.lineFragmentPadding)) 42 | } 43 | 44 | // scroll to visible IN clip view (ignoring gutter view overlay) 45 | // adjust rect to mimick it's size to include gutter overlay 46 | rect.origin.x -= gutterView?.frame.width ?? 0 47 | rect.size.width += gutterView?.frame.width ?? 0 48 | 49 | // put rect origin in the center 50 | contentView.scroll(rect.origin.applying(.init(translationX: 0, y: -visibleRect.height / 2))) 51 | } 52 | 53 | open override func pageUp(_ sender: Any?) { 54 | scrollPageUp(sender) 55 | } 56 | 57 | open override func pageUpAndModifySelection(_ sender: Any?) { 58 | pageUp(sender) 59 | } 60 | 61 | open override func pageDown(_ sender: Any?) { 62 | scrollPageDown(sender) 63 | } 64 | 65 | open override func pageDownAndModifySelection(_ sender: Any?) { 66 | pageDown(sender) 67 | } 68 | 69 | open override func scrollPageDown(_ sender: Any?) { 70 | scroll(visibleRect.moved(dy: visibleRect.height).origin) 71 | } 72 | 73 | open override func scrollPageUp(_ sender: Any?) { 74 | scroll(visibleRect.moved(dy: -visibleRect.height).origin) 75 | } 76 | 77 | open override func scrollToBeginningOfDocument(_ sender: Any?) { 78 | scroll(CGPoint(x: visibleRect.origin.x, y: frame.minY)) 79 | } 80 | 81 | open override func scrollToEndOfDocument(_ sender: Any?) { 82 | scroll(CGPoint(x: visibleRect.origin.x, y: frame.maxY)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Speech.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import AppKit 6 | import AVFoundation 7 | 8 | extension STTextView { 9 | 10 | /// Speaks the selected text, or all text if no selection. 11 | @objc open func startSpeaking(_ sender: Any?) { 12 | stopSpeaking(sender) 13 | 14 | let attrString: NSAttributedString 15 | let selectionTextRanges = textLayoutManager.textSelectionsRanges(.withoutInsertionPoints) 16 | if selectionTextRanges.isEmpty { 17 | attrString = attributedString() 18 | } else { 19 | attrString = textLayoutManager.textAttributedString(in: selectionTextRanges) ?? NSAttributedString() 20 | } 21 | 22 | if !attrString.isEmpty { 23 | let utterance = AVSpeechUtterance(attributedString: attrString) 24 | utterance.prefersAssistiveTechnologySettings = true 25 | speechSynthesizer.speak(utterance) 26 | } 27 | } 28 | 29 | /// Stops the speaking of text. 30 | @objc open func stopSpeaking(_ sender: Any?) { 31 | if speechSynthesizer.isSpeaking { 32 | speechSynthesizer.stopSpeaking(at: .word) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Undo.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import AppKit 6 | 7 | // NSResponder.undoManager doesn't work out of the box (as 03.2022, macOS 12.3) 8 | // see https://gist.github.com/krzyzanowskim/1a13f27e6b469ca2ffcf9b53588b837a 9 | 10 | extension STTextView { 11 | 12 | open override var undoManager: UndoManager? { 13 | guard allowsUndo else { 14 | return nil 15 | } 16 | 17 | return delegateProxy.undoManager(for: self) ?? _undoManager 18 | } 19 | 20 | @objc func undo(_ sender: AnyObject?) { 21 | if allowsUndo { 22 | undoManager?.undo() 23 | } 24 | } 25 | 26 | @objc func redo(_ sender: AnyObject?) { 27 | if allowsUndo { 28 | undoManager?.redo() 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/STTextView+Yank.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | extension STTextView { 7 | 8 | /// Yanking means reinserting text previously killed. The usual way to move or copy text is to kill it and then yank it elsewhere. 9 | /// 10 | /// https://www.gnu.org/software/emacs/manual/html_node/emacs/Yanking.html 11 | open override func yank(_ sender: Any?) { 12 | replaceCharacters( 13 | in: textLayoutManager.insertionPointSelections.flatMap(\.textRanges), 14 | with: _yankingManager.yank(), 15 | useTypingAttributes: true, 16 | allowsTypingCoalescing: false 17 | ) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/Utility/NSColor+TextInsertionPoint.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | extension NSColor { 7 | static var defaultTextInsertionPoint: NSColor { 8 | if #available(macOS 14, *) { 9 | NSColor.textInsertionPointColor 10 | } else { 11 | NSColor.controlAccentColor 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/STTextViewAppKit/YankingManager.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | /// Yanking means reinserting text previously killed. The usual way to move or copy text is to kill it and then yank it elsewhere. 7 | /// https://www.gnu.org/software/emacs/manual/html_node/emacs/Yanking.html 8 | final class YankingManager { 9 | 10 | enum DeleteAction { 11 | case delete 12 | case deleteToMark 13 | case deleteWordForward 14 | case deleteWordBackward 15 | case deleteToBeginningOfLine 16 | case deleteToEndOfLine 17 | case deleteToBeginningOfParagraph 18 | case deleteToEndOfParagraph 19 | } 20 | 21 | private var index: Int = 0 22 | private var buffer: [String] 23 | private var yanking: Bool = false 24 | private var lastDeleteAction: DeleteAction? 25 | 26 | init() { 27 | let killRingSize = UserDefaults.standard.integer(forKey: "NSTextKillRingSize") 28 | buffer = Array(repeating: "", count: max(killRingSize, 1)) 29 | } 30 | 31 | /// Call when selection changes in editor 32 | func selectionChanged() { 33 | lastDeleteAction = nil 34 | } 35 | 36 | /// Call when text changes in editor 37 | func textChanged() { 38 | yanking = false 39 | lastDeleteAction = nil 40 | } 41 | 42 | /// Call when text editor performs any of the defined Actions. 43 | /// The editor should perform all selection and buffer mutations within the `killBlock`. 44 | func kill(action: DeleteAction, string: String) { 45 | let savedLastAction = lastDeleteAction 46 | 47 | if action != savedLastAction { 48 | lastDeleteAction = action 49 | 50 | if index == buffer.count - 1 { 51 | index = 0 52 | } else { 53 | index += 1 54 | } 55 | 56 | buffer[index] = "" 57 | } else { 58 | self.lastDeleteAction = savedLastAction 59 | } 60 | 61 | switch action { 62 | case .delete: 63 | buffer[index] = string 64 | case .deleteToBeginningOfLine, .deleteToBeginningOfParagraph, .deleteWordBackward: 65 | buffer[index] = string + buffer[index] 66 | case .deleteToEndOfLine, .deleteToEndOfParagraph, .deleteWordForward, .deleteToMark: 67 | buffer[index] = buffer[index] + string 68 | } 69 | } 70 | 71 | /// Call in response to `yank:` action. 72 | func yank() -> String { 73 | yanking = true 74 | return buffer[index] 75 | } 76 | 77 | /// Call in response to `yankAndSelect:` action. 78 | func yankAndSelect() -> String { 79 | if yanking { 80 | if index == 0 { 81 | index = buffer.count - 1 82 | } else { 83 | index -= 1 84 | } 85 | } 86 | yanking = true 87 | return buffer[index] 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/CoalescingUndoManager.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | import STTextKitPlus 12 | 13 | package class CoalescingUndoManager: UndoManager { 14 | 15 | private var lastRange: NSTextRange? 16 | 17 | private var isCoalescing: Bool = false 18 | 19 | package override init() { 20 | super.init() 21 | #if os(macOS) 22 | self.runLoopModes = [.default, .common, .eventTracking, .modalPanel] 23 | #else 24 | self.runLoopModes = [.default, .common, .tracking] 25 | #endif 26 | self.groupsByEvent = false 27 | } 28 | 29 | package override func undo() { 30 | if isCoalescing { 31 | endCoalescing() 32 | } 33 | super.undo() 34 | } 35 | 36 | package override func redo() { 37 | if isCoalescing { 38 | endCoalescing() 39 | } 40 | super.redo() 41 | } 42 | 43 | package func checkCoalescing(range: NSTextRange) { 44 | defer { 45 | lastRange = range 46 | } 47 | guard isCoalescing, let lastRange else { 48 | startCoalescing() 49 | return 50 | } 51 | if !lastRange.intersects(range) && lastRange.endLocation != range.location { 52 | endCoalescing() 53 | startCoalescing() 54 | } 55 | } 56 | 57 | package func startCoalescing() { 58 | guard !isCoalescing else { return } 59 | isCoalescing = true 60 | beginUndoGrouping() 61 | } 62 | 63 | package func endCoalescing() { 64 | guard isCoalescing else { return } 65 | isCoalescing = false 66 | lastRange = nil 67 | endUndoGrouping() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Extensions/Geometric+Helpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import CoreGraphics 5 | 6 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 7 | import AppKit 8 | #endif 9 | #if canImport(UIKit) 10 | import UIKit 11 | #endif 12 | 13 | package extension CGRect { 14 | 15 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 16 | typealias EdgeInsets = NSEdgeInsets 17 | #else 18 | typealias EdgeInsets = UIEdgeInsets 19 | #endif 20 | 21 | enum Inset { 22 | case left(CGFloat) 23 | case right(CGFloat) 24 | case top(CGFloat) 25 | case bottom(CGFloat) 26 | } 27 | 28 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 29 | func inset(by edgeInsets: EdgeInsets) -> CGRect { 30 | var result = self 31 | result.origin.x += edgeInsets.left 32 | result.origin.y += edgeInsets.top 33 | result.size.width -= edgeInsets.left + edgeInsets.right 34 | result.size.height -= edgeInsets.top + edgeInsets.bottom 35 | return result 36 | } 37 | #endif 38 | 39 | func inset(_ insets: Inset...) -> CGRect { 40 | var result = self 41 | for inset in insets { 42 | switch inset { 43 | case .left(let value): 44 | result = self.inset(by: EdgeInsets(top: 0, left: value, bottom: 0, right: 0)) 45 | case .right(let value): 46 | result = self.inset(by: EdgeInsets(top: 0, left: 0, bottom: 0, right: value)) 47 | case .top(let value): 48 | result = self.inset(by: EdgeInsets(top: value, left: 0, bottom: 0, right: 0)) 49 | case .bottom(let value): 50 | result = self.inset(by: EdgeInsets(top: 0, left: 0, bottom: value, right: 0)) 51 | } 52 | } 53 | return result 54 | } 55 | 56 | func inset(dx: CGFloat = 0, dy: CGFloat = 0) -> CGRect { 57 | insetBy(dx: dx, dy: dy) 58 | } 59 | 60 | func scale(_ scale: CGSize) -> CGRect { 61 | applying(.init(scaleX: scale.width, y: scale.height)) 62 | } 63 | 64 | func margin(_ margin: CGSize) -> CGRect { 65 | insetBy(dx: -margin.width / 2, dy: -margin.height / 2) 66 | } 67 | 68 | func moved(dx: CGFloat = 0, dy: CGFloat = 0) -> CGRect { 69 | applying(.init(translationX: dx, y: dy)) 70 | } 71 | 72 | func moved(by point: CGPoint) -> CGRect { 73 | applying(.init(translationX: point.x, y: point.y)) 74 | } 75 | 76 | func margin(top: CGFloat = 0, left: CGFloat = 0, bottom: CGFloat = 0, right: CGFloat = 0) -> CGRect { 77 | inset(by: .init(top: -top, left: -left, bottom: -bottom, right: -right)) 78 | } 79 | } 80 | 81 | package extension CGPoint { 82 | func moved(dx: CGFloat = 0, dy: CGFloat = 0) -> CGPoint { 83 | applying(.init(translationX: dx, y: dy)) 84 | } 85 | 86 | func moved(by point: CGPoint) -> CGPoint { 87 | applying(.init(translationX: point.x, y: point.y)) 88 | } 89 | } 90 | 91 | package extension CGRect { 92 | func isAlmostEqual(to other: CGRect) -> Bool { 93 | origin.isAlmostEqual(to: other.origin) && size.isAlmostEqual(to: other.size) 94 | } 95 | } 96 | 97 | package extension CGPoint { 98 | func isAlmostEqual(to other: CGPoint) -> Bool { 99 | x.isAlmostEqual(to: other.x) && y.isAlmostEqual(to: other.y) 100 | } 101 | } 102 | 103 | package extension CGSize { 104 | func isAlmostEqual(to other: CGSize) -> Bool { 105 | width.isAlmostEqual(to: other.width) && height.isAlmostEqual(to: other.height) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Extensions/NSAttributedString+Helpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | package extension NSAttributedString { 7 | func attributes(in range: NSRange, options: NSAttributedString.EnumerationOptions = []) -> Set { 8 | var usedAttributes: Set = [] 9 | 10 | enumerateAttributes(in: range, options: .longestEffectiveRangeNotRequired) { attrs, _, _ in 11 | // enumeration block executed at least once without a good reason FB12863947 12 | // https://gist.github.com/krzyzanowskim/1c07715c5193382562b2e597379a8e4b 13 | if !attrs.isEmpty { 14 | usedAttributes.formUnion(attrs.keys) 15 | } 16 | } 17 | return usedAttributes 18 | } 19 | 20 | func attribute(_ attrName: NSAttributedString.Key, in range: NSRange, options: NSAttributedString.EnumerationOptions = []) -> [(range: NSRange, value: Any?)] { 21 | var ranges: [(range: NSRange, value: Any?)] = [] 22 | enumerateAttribute(attrName, in: range, options: options) { value, range, _ in 23 | // enumeration block executed at least once without a good reason FB12863947 24 | // https://gist.github.com/krzyzanowskim/1c07715c5193382562b2e597379a8e4b 25 | if value != nil { 26 | ranges.append((range: range, value: value)) 27 | } 28 | } 29 | return ranges 30 | } 31 | 32 | var range: NSRange { 33 | NSRange(location: 0, length: length) 34 | } 35 | 36 | var isEmpty: Bool { 37 | length == 0 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Extensions/NSParagraphStyle+Helpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | package extension NSParagraphStyle { 12 | 13 | /// Normalized value for default paragraph style 14 | /// NSParagraphStyle.lineHeightMultiple means "default" multiple, that is 1.0 15 | var stLineHeightMultiple: CGFloat { 16 | if lineHeightMultiple.isAlmostZero() { 17 | return 1.0 18 | } else { 19 | return lineHeightMultiple 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Extensions/NSRange+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | package extension NSRange { 4 | func clamped(_ limits: NSRange) -> Self? { 5 | guard let limits = Range(limits), 6 | let clampedRange = Range(self)?.clamped(to: limits) 7 | else { 8 | return nil 9 | } 10 | 11 | return Self(clampedRange) 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Extensions/NSTextLayoutManager+Selection.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | import STTextKitPlus 12 | 13 | package struct TextSelectionRangesOptions: OptionSet { 14 | package let rawValue: UInt 15 | package static let withoutInsertionPoints = TextSelectionRangesOptions(rawValue: 1 << 0) 16 | package static let withInsertionPoints = TextSelectionRangesOptions(rawValue: 1 << 1) 17 | 18 | package init(rawValue: UInt) { 19 | self.rawValue = rawValue 20 | } 21 | } 22 | 23 | package extension NSTextLayoutManager { 24 | 25 | /// A String in range 26 | /// - Parameter range: Text range 27 | /// - Returns: String in the range 28 | func substring(in range: NSTextRange) -> String { 29 | guard !range.isEmpty else { return "" } 30 | var output = String() 31 | if let textContentManager { 32 | output.reserveCapacity(range.length(in: textContentManager)) 33 | } else { 34 | output.reserveCapacity(128) 35 | } 36 | enumerateSubstrings(from: range.location, options: .byComposedCharacterSequences) { (substring, substringRange, _, stop) in 37 | let shouldContinue = substringRange.location <= range.endLocation 38 | if !shouldContinue { 39 | stop.pointee = true 40 | return 41 | } 42 | 43 | if let substring = substring { 44 | output += substring 45 | } 46 | } 47 | return output 48 | } 49 | 50 | func textSelectionsRanges(_ options: TextSelectionRangesOptions = .withInsertionPoints) -> [NSTextRange] { 51 | if options.contains(.withoutInsertionPoints) { 52 | return textSelections.flatMap(\.textRanges).filter({ !$0.isEmpty }).sorted(by: { $0.location < $1.location }) 53 | } else { 54 | return textSelections.flatMap(\.textRanges).sorted(by: { $0.location < $1.location }) 55 | } 56 | } 57 | 58 | func textSelectionsString() -> String? { 59 | textSelectionsRanges(.withoutInsertionPoints).compactMap { textRange in 60 | substring(in: textRange) 61 | }.joined(separator: "\n") 62 | } 63 | 64 | func textSelectionsAttributedString() -> NSAttributedString? { 65 | textAttributedString(in: textSelectionsRanges(.withoutInsertionPoints)) 66 | } 67 | 68 | func textAttributedString(at location: any NSTextLocation) -> NSAttributedString? { 69 | if let range = NSTextRange(location: location, end: self.location(location, offsetBy: 1)), !range.isEmpty { 70 | return textAttributedString(in: range) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func textAttributedString(in textRange: NSTextRange) -> NSAttributedString? { 77 | textAttributedString(in: [textRange]) 78 | } 79 | 80 | func textAttributedString(in textRanges: [NSTextRange]) -> NSAttributedString? { 81 | let attributedString = textRanges.reduce(NSMutableAttributedString()) { partialResult, range in 82 | if let attributedString = textContentManager?.attributedString(in: range) { 83 | if partialResult.length != 0 { 84 | partialResult.append(NSAttributedString(string: "\n")) 85 | } 86 | partialResult.append(attributedString) 87 | } 88 | return partialResult 89 | } 90 | 91 | if attributedString.length == 0 { 92 | return nil 93 | } 94 | return attributedString 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Extensions/NStextLayoutFragment+isExtraLineFragment.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | extension NSTextLayoutFragment { 12 | package var isExtraLineFragment: Bool { 13 | textLineFragments.contains(where: \.isExtraLineFragment) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/STAttributedTextElement.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | /// An attributed string backed text element 12 | package protocol STAttributedTextElement: NSTextElement { 13 | var attributedString: NSAttributedString { get } 14 | } 15 | 16 | extension NSTextParagraph: STAttributedTextElement { } 17 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/STMarkedText.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | package final class STMarkedText: CustomDebugStringConvertible { 7 | package var markedText: NSAttributedString 8 | package var markedRange: NSRange 9 | 10 | // Not used currently in STTextView. 11 | // that turned out to be good because it's buggy FB13789916 https://gist.github.com/krzyzanowskim/340c5810fc427e346b7c4b06d46b1e10 12 | package var selectedRange: NSRange 13 | 14 | package init(markedText: NSAttributedString, markedRange: NSRange, selectedRange: NSRange) { 15 | self.markedText = markedText 16 | self.markedRange = markedRange 17 | self.selectedRange = selectedRange 18 | } 19 | 20 | package var debugDescription: String { 21 | "markedText: \"\(markedText.string)\", markedRange: \(markedRange), selectedRange: \(selectedRange)" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/STTextContentStorage.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | import STTextKitPlus 12 | 13 | open class STTextContentStorage: NSTextContentStorage { 14 | 15 | open override func replaceContents(in range: NSTextRange, with textElements: [NSTextElement]?) { 16 | assert(hasEditingTransaction, "Not called inside performEditingTransaction") 17 | 18 | guard let textStorage = textStorage, 19 | let attributedTextElements = textElements?.compactMap({ $0 as? STAttributedTextElement }) 20 | else { 21 | // Non-functional (FB9925647) 22 | super.replaceContents(in: range, with: textElements) 23 | assertionFailure() 24 | return 25 | } 26 | 27 | let replacementString = NSMutableAttributedString() 28 | replacementString.beginEditing() 29 | for attributedTextElement in attributedTextElements { 30 | replacementString.append(attributedTextElement.attributedString) 31 | } 32 | replacementString.endEditing() 33 | 34 | // Replace text and (unexpectedly) reset textSelections in 35 | // -[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:changeInLength:] 36 | // that breaks multi cursor setup 37 | // Workaround: Fix _fixSelectionAfterChangeInCharacterRange nad fix selection by myself 38 | 39 | textStorage.beginEditing() 40 | textStorage.replaceCharacters( 41 | in: NSRange(range, in: self), 42 | with: replacementString 43 | ) 44 | 45 | // endEditing updates `NSTextLayoutManager.textSelections` value 46 | // that behavior may be undesired in certain scenarios where change suppose to keep the selection intact (at least adjusted) 47 | textStorage.endEditing() 48 | 49 | fix_fixSelectionAfterChangeInCharacterRange() 50 | } 51 | 52 | // Fix a result of the NSTextLayoutManager._fixSelectionAfterChangeInCharacterRange 53 | // specifically: duplicated (identical) ranges and selections 54 | private func fix_fixSelectionAfterChangeInCharacterRange() { 55 | // Remove duplicated selections that are result of _fixSelectionAfterChangeInCharacterRange 56 | for textLayoutManager in textLayoutManagers { 57 | let origSelections = textLayoutManager.textSelections 58 | var uniqueSelections: [NSTextSelection] = [] 59 | uniqueSelections.reserveCapacity(origSelections.count) 60 | 61 | // Remove duplicated selections 62 | for selection in origSelections { 63 | if !uniqueSelections.contains(where: { $0.textRanges == selection.textRanges }) { 64 | uniqueSelections.append(selection) 65 | } 66 | } 67 | 68 | // Remove duplicated textRanges in selections 69 | var finalSelections: [NSTextSelection] = [] 70 | finalSelections.reserveCapacity(uniqueSelections.count) 71 | for selection in uniqueSelections { 72 | 73 | var uniqueRanges: [NSTextRange] = [] 74 | uniqueRanges.reserveCapacity(selection.textRanges.count) 75 | for textRange in selection.textRanges { 76 | if !uniqueRanges.contains(where: { $0 == textRange }) { 77 | uniqueRanges.append(textRange) 78 | } 79 | } 80 | 81 | let selectionCopy = NSTextSelection(uniqueRanges, affinity: selection.affinity, granularity: selection.granularity) 82 | selectionCopy.anchorPositionOffset = selection.anchorPositionOffset 83 | selectionCopy.isLogical = selection.isLogical 84 | selectionCopy.typingAttributes = selection.typingAttributes 85 | finalSelections.append(selectionCopy) 86 | } 87 | 88 | textLayoutManager.textSelections = finalSelections 89 | } 90 | } 91 | 92 | // override func enumerateTextElements(from textLocation: NSTextLocation?, options: NSTextContentManager.EnumerationOptions = [], using block: (NSTextElement) -> Bool) -> NSTextLocation? { 93 | // super.enumerateTextElements(from: textLocation, options: options, using: block) 94 | // } 95 | // 96 | // override func recordEditAction(in originalTextRange: NSTextRange, newTextRange: NSTextRange) { 97 | // super.recordEditAction(in: originalTextRange, newTextRange: newTextRange) 98 | // } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/STTextLayoutManager.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | import STTextKitPlus 12 | 13 | open class STTextLayoutManager: NSTextLayoutManager { 14 | 15 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 16 | /// Posted when the selected range of characters changes. 17 | public static let didChangeSelectionNotification = NSTextView.didChangeSelectionNotification 18 | #else 19 | /// Posted when the selected range of characters changes. 20 | public static let didChangeSelectionNotification = NSNotification.Name("STTextView.didChangeSelectionNotification") 21 | #endif 22 | 23 | private static let needsBoundsWorkaround = testIfNeedsBoundsWorkaround() 24 | 25 | public override var textSelections: [NSTextSelection] { 26 | didSet { 27 | let notification = Notification(name: Self.didChangeSelectionNotification, object: self, userInfo: nil) 28 | NotificationCenter.default.post(notification) 29 | } 30 | } 31 | 32 | @objc open dynamic override var usageBoundsForTextContainer: CGRect { 33 | var rect = super.usageBoundsForTextContainer 34 | if Self.needsBoundsWorkaround { 35 | // FB13290979: NSTextContainer.lineFragmentPadding does not affect end of the fragment usageBoundsForTextContainer rectangle 36 | // https://gist.github.com/krzyzanowskim/7adc5ee66be68df2f76b9752476aadfb 37 | // Changed in macOS 14 https://developer.apple.com/documentation/macos-release-notes/appkit-release-notes-for-macos-14#TextKit-API-Coordinate-System-Changes 38 | // NSTextLineFragment.typographicBounds.size.width doesn’t contain NSTextContainer.lineFragmentPadding 39 | rect.size.width += textContainer?.lineFragmentPadding ?? 0 40 | } 41 | return rect 42 | } 43 | 44 | } 45 | 46 | 47 | // Changed in macOS 14 https://developer.apple.com/documentation/macos-release-notes/appkit-release-notes-for-macos-14#TextKit-API-Coordinate-System-Changes 48 | private func testIfNeedsBoundsWorkaround() -> Bool { 49 | if #available(macOS 14, iOS 17, *) { 50 | return true 51 | } 52 | 53 | return false 54 | } 55 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/STTextViewProtocol.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | /// A common public interface for TextView 12 | package protocol STTextViewProtocol { 13 | associatedtype GutterView 14 | associatedtype Color 15 | associatedtype Font 16 | associatedtype Delegate 17 | 18 | static var didChangeSelectionNotification: NSNotification.Name { get } 19 | static var textWillChangeNotification: NSNotification.Name { get } 20 | static var textDidChangeNotification: NSNotification.Name { get } 21 | 22 | var textLayoutManager: NSTextLayoutManager { get } 23 | var textContentManager: NSTextContentManager { get } 24 | var textContainer: NSTextContainer { get set } 25 | 26 | var widthTracksTextView: Bool { get set } 27 | var isHorizontallyResizable: Bool { get set } 28 | var heightTracksTextView: Bool { get set } 29 | var isVerticallyResizable: Bool { get set } 30 | 31 | var highlightSelectedLine: Bool { get set } 32 | var selectedLineHighlightColor: Color { get set} 33 | 34 | var showsLineNumbers: Bool { get set } 35 | var showsInvisibleCharacters: Bool { get set } 36 | 37 | var font: Font { get set } 38 | var textColor: Color { get set } 39 | var defaultParagraphStyle: NSParagraphStyle { get set } 40 | 41 | var typingAttributes: [NSAttributedString.Key: Any] { get } 42 | 43 | var text: String? { get set } 44 | var attributedText: NSAttributedString? { get set } 45 | 46 | var isEditable: Bool { get set } 47 | var isSelectable: Bool { get set } 48 | var allowsUndo: Bool { get set } 49 | 50 | var textDelegate: Delegate? { get set } 51 | 52 | var gutterView: GutterView? { get } 53 | func toggleRuler(_ sender: Any?) 54 | 55 | var textSelection: NSRange { get set } 56 | 57 | func addAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange) 58 | func setAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange) 59 | func removeAttribute(_ attribute: NSAttributedString.Key, range: NSRange) 60 | 61 | func shouldChangeText(in affectedTextRange: NSTextRange, replacementString: String?) -> Bool 62 | func replaceCharacters(in range: NSTextRange, with string: String) 63 | func insertText(_ string: Any, replacementRange: NSRange) 64 | } 65 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Utilities/LineHeight.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 5 | import AppKit 6 | #endif 7 | #if canImport(UIKit) 8 | import UIKit 9 | #endif 10 | 11 | package func calculateDefaultLineHeight(for font: CTFont) -> CGFloat { 12 | /// Heavily inspired by WebKit 13 | 14 | let kLineHeightAdjustment: CGFloat = 0.15 15 | 16 | var ascent = CTFontGetAscent(font) 17 | var descent = CTFontGetDescent(font) 18 | var lineGap = CTFontGetLeading(font) 19 | 20 | let familyName = CTFontCopyFamilyName(font) as String 21 | 22 | if shouldUseAdjustment(familyName) { 23 | // Needs ascent adjustment 24 | ascent += round((ascent + descent) * kLineHeightAdjustment); 25 | } 26 | 27 | // Compute line spacing before the line metrics hacks are applied. 28 | var lineSpacing = round(ascent) + round(descent) + round(lineGap); 29 | 30 | // Hack Hiragino line metrics to allow room for marked text underlines. 31 | if descent < 3, lineGap >= 3, familyName.hasPrefix("Hiragino") == true { 32 | lineGap -= 3 - descent 33 | descent = 3 34 | } 35 | 36 | #if os(iOS) || targetEnvironment(macCatalyst) 37 | let adjustment = shouldUseAdjustment(familyName) ? ceil(ascent + descent) * kLineHeightAdjustment : 0 38 | lineGap = ceil(lineGap) 39 | lineSpacing = ceil(ascent) + adjustment + ceil(descent) + lineGap 40 | ascent = ceil((ascent + adjustment)) 41 | descent = ceil(descent) 42 | #endif 43 | 44 | return lineSpacing 45 | } 46 | 47 | private func shouldUseAdjustment(_ familyName: String) -> Bool { 48 | familyName.caseInsensitiveCompare("Times") == .orderedSame 49 | || familyName.caseInsensitiveCompare("Helvetica") == .orderedSame 50 | || familyName.caseInsensitiveCompare("Courier") == .orderedSame // macOS only 51 | || familyName.caseInsensitiveCompare(".Helvetica NeueUI") == .orderedSame 52 | } 53 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Utilities/STRulerInsets.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | public struct STRulerInsets: Equatable { 7 | public let leading: CGFloat 8 | public let trailing: CGFloat 9 | 10 | public init(leading: CGFloat = 0, trailing: CGFloat = 0) { 11 | self.leading = leading 12 | self.trailing = trailing 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Utilities/Throttler/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jang Seoksoon 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 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Utilities/Throttler/debounce.swift: -------------------------------------------------------------------------------- 1 | // 2 | // debounce.swift 3 | // Throttler 4 | // 5 | // Created by seoksoon jang on 2023-04-03. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | Debounce Function 12 | 13 | - Parameters: 14 | - duration: Foundation `Duration` type such as `.seconds(2.0)`. Default is .seconds(1.0) 15 | - identifier: A unique identifier for this debounce operation. By default, it uses the call stack symbols as the identifier. You can provide a custom identifier to group related debounce operations. It is highly recommended to use your own identifier to avoid unexpected behavior, but you can use the internal stack trace identifier at your own risk. 16 | - actorType: The actor context in which to run the operation. Use `.main` to run the operation on the main actor or `.current` for the current actor context. 17 | - option: The debounce option to control the behavior of the debounce operation. You can choose between `.default` and `.runFirst`. The default behavior delays the operation execution by the specified duration, while `runFirst` executes the operation immediately and applies debounce to subsequent calls. 18 | - operation: The operation to debounce. This is a closure that contains the code to be executed when the debounce conditions are met. 19 | 20 | - Note: 21 | - The provided `identifier` is used to group related debounce operations. If multiple debounce calls share the same identifier, they will be considered as part of the same group, and the debounce behavior will apply collectively. 22 | - This method ensures that the operation is executed in a thread-safe manner within the specified actor context. 23 | 24 | - Example: 25 | ```swift 26 | // Debounce a button tap action to prevent rapid execution. 27 | @IBAction func buttonTapped(_ sender: UIButton) { 28 | // Basic debounce with default options 29 | 30 | debounce { 31 | print("Button tapped (debounced with default option)") 32 | } 33 | 34 | // Using custom identifiers 35 | 36 | debounce(.seconds(1.0), identifier: "customIdentifier") { 37 | print("Custom debounced operation with identifier") 38 | } 39 | 40 | // Using 'runFirst' option to execute the first operation immediately and debounce the rest 41 | 42 | debounce(.seconds(1.0), identifier: "runFirstExample", option: .runFirst) { 43 | print("Debounced operation using runFirst option") 44 | } 45 | } 46 | ``` 47 | 48 | - See Also: 49 | - DebounceOptions: Enum that defines various options for controlling debounce behavior. 50 | */ 51 | 52 | package func debounce( 53 | _ duration: TimeInterval = 1.0, 54 | identifier: String = "\(Thread.callStackSymbols)", 55 | by `actor`: ActorType = .mainActor, 56 | option: DebounceOptions = .default, 57 | operation: @escaping () -> Void 58 | ) { 59 | Task { 60 | await throttler.debounce( 61 | duration, 62 | identifier: identifier, 63 | by: actor, 64 | option: option, 65 | operation: operation 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Utilities/Throttler/delay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // delay.swift 3 | // Throttler 4 | // 5 | // Created by seoksoon jang on 2023-04-03. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | Delays the execution of a provided operation by a specified time duration. 12 | 13 | - Parameters: 14 | - duration: Foundation `Duration` type such sa .seconds(2.0). By default, .seconds(1.0) 15 | - actorType: The actor type on which the operation should be executed (default is `.main`). 16 | - operation: The operation to be executed after the delay. 17 | 18 | - Note: 19 | - The provided `identifier` is used to group related debounce operations. If multiple debounce calls share the same identifier, they will be considered as part of the same group, and the debounce behavior will apply collectively. 20 | - This method ensures that the operation is executed in a thread-safe manner within the specified actor context. 21 | 22 | - Usage: 23 | ```swift 24 | // Delay execution by 2 seconds using a custom duration. 25 | delay(.seconds(2)) { 26 | print("Delayed operation") 27 | } 28 | 29 | // Alternatively, delay execution by 1.5 seconds using the .seconds convenience method. 30 | delay(.seconds(1.5)) { 31 | print("Another delayed operation") 32 | } 33 | ``` 34 | */ 35 | 36 | package func delay( 37 | _ duration: TimeInterval = 1.0, 38 | by `actor`: ActorType = .mainActor, 39 | operation: @escaping () -> Void 40 | ) { 41 | Task { 42 | await throttler.delay( 43 | duration, 44 | by: actor, 45 | operation: operation 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/STTextViewCommon/Utilities/Throttler/throttle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // throttle.swift 3 | // Throttler 4 | // 5 | // Created by seoksoon jang on 2023-04-03. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | Limits the frequency of executing a given operation to ensure it is not called more frequently than a specified duration. 12 | 13 | - Parameters: 14 | - duration: Foundation `Duration` type such as `.seconds(2.0)`. By default, .seconds(1.0) 15 | - identifier: (Optional) An identifier to distinguish between throttled operations. It is highly recommended to provide a custom identifier for clarity and to avoid potential issues with long call stack symbols. Use at your own risk with internal stack traces. 16 | - actorType: The actor type on which the operation should be executed (default is `.main`). 17 | - option: An option to customize the behavior of the throttle (default is `.default`). 18 | - operation: The operation to be executed when throttled. 19 | 20 | - Note: 21 | - The provided `identifier` is used to group related throttle operations. If multiple throttle calls share the same identifier, they will be considered as part of the same group, and the throttle behavior will apply collectively. 22 | - This method ensures that the operation is executed in a thread-safe manner within the specified actor context. 23 | 24 | - Usage: 25 | ```swift 26 | // Throttle a button tap action to prevent rapid execution. 27 | @IBAction func buttonTapped(_ sender: UIButton) { 28 | // Basic usage with default options 29 | 30 | throttle { 31 | print("Button tapped (throttled with default option)") 32 | } 33 | 34 | // Using custom identifiers to distinguish between throttled operations 35 | 36 | throttle(.seconds(3.0), identifier: "customIdentifier") { 37 | print("Custom throttled operation with identifier") 38 | } 39 | 40 | // Using 'ensureLast' option to guarantee that the last call is executed 41 | 42 | throttle(.seconds(3.0), identifier: "ensureLastExample", option: .ensureLast) { 43 | print("Throttled operation using ensureLast option") 44 | } 45 | } 46 | ``` 47 | 48 | - See Also: 49 | - ThrottleOptions: Enum that defines various options for controlling throttle behavior. 50 | 51 | */ 52 | 53 | package func throttle( 54 | _ duration: TimeInterval = 1.0, 55 | identifier: String = "\(Thread.callStackSymbols)", 56 | by `actor`: ActorType = .mainActor, 57 | option: ThrottleOptions = .default, 58 | operation: @escaping () -> Void 59 | ) { 60 | Task { 61 | await throttler.throttle( 62 | duration, 63 | identifier: identifier, 64 | by: actor, 65 | option: option, 66 | operation: operation 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/STTextViewSwiftUI/module.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if os(macOS) 4 | @_exported import STTextViewSwiftUIAppKit 5 | #endif 6 | 7 | #if os(iOS) || targetEnvironment(macCatalyst) 8 | @_exported import STTextViewSwiftUIUIKit 9 | #endif 10 | -------------------------------------------------------------------------------- /Sources/STTextViewSwiftUIAppKit/TextViewModifier.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import SwiftUI 6 | 7 | public protocol TextViewModifier: SwiftUI.View { } 8 | 9 | extension TextViewModifier { 10 | 11 | /// Sets the default font for text in this view. 12 | public func textViewFont(_ font: NSFont) -> TextViewEnvironmentModifier { 13 | TextViewEnvironmentModifier(content: self, keyPath: \.font, value: font) 14 | } 15 | } 16 | 17 | public struct TextViewEnvironmentModifier: View, TextViewModifier { 18 | let content: Content 19 | let keyPath: WritableKeyPath 20 | let value: V 21 | 22 | public var body: some View { 23 | content 24 | .environment(keyPath, value) 25 | } 26 | } 27 | 28 | private struct FontEnvironmentKey: EnvironmentKey { 29 | static var defaultValue: NSFont = .preferredFont(forTextStyle: .body) 30 | } 31 | 32 | internal extension EnvironmentValues { 33 | var font: NSFont { 34 | get { self[FontEnvironmentKey.self] } 35 | set { self[FontEnvironmentKey.self] = newValue } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/STTextViewSwiftUIUIKit/TextViewModifier.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import SwiftUI 6 | 7 | public protocol TextViewModifier: SwiftUI.View { } 8 | 9 | extension TextViewModifier { 10 | 11 | /// Sets the default font for text in this view. 12 | public func textViewFont(_ font: UIFont) -> TextViewEnvironmentModifier { 13 | TextViewEnvironmentModifier(content: self, keyPath: \.font, value: font) 14 | } 15 | } 16 | 17 | public struct TextViewEnvironmentModifier: View, TextViewModifier { 18 | let content: Content 19 | let keyPath: WritableKeyPath 20 | let value: V 21 | 22 | public var body: some View { 23 | content 24 | .environment(keyPath, value) 25 | } 26 | } 27 | 28 | private struct FontEnvironmentKey: EnvironmentKey { 29 | static var defaultValue: UIFont = .preferredFont(forTextStyle: .body) 30 | } 31 | 32 | internal extension EnvironmentValues { 33 | var font: UIFont { 34 | get { self[FontEnvironmentKey.self] } 35 | set { self[FontEnvironmentKey.self] = newValue } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Gutter/STGutterLineNumberCell.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | import STTextViewCommon 6 | 7 | final class STGutterLineNumberCell: UIView { 8 | /// Line number 9 | let lineNumber: Int 10 | private let firstBaseline: CGFloat 11 | private let ctLine: CTLine 12 | private let textWidth: CGFloat 13 | var insets: STRulerInsets = STRulerInsets() 14 | 15 | override var debugDescription: String { 16 | "\(super.debugDescription) (number: \(lineNumber))" 17 | } 18 | 19 | init(firstBaseline: CGFloat, attributes: [NSAttributedString.Key: Any], number: Int) { 20 | self.lineNumber = number 21 | self.firstBaseline = firstBaseline 22 | 23 | let attributedString = NSAttributedString(string: "\(number)", attributes: attributes) 24 | self.ctLine = CTLineCreateWithAttributedString(attributedString) 25 | self.textWidth = ceil(CTLineGetTypographicBounds(ctLine, nil, nil, nil)) 26 | 27 | super.init(frame: .zero) 28 | clipsToBounds = true 29 | isOpaque = false 30 | isUserInteractionEnabled = false 31 | } 32 | 33 | @available(*, unavailable) 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | override var intrinsicContentSize: CGSize { 39 | CGSize(width: textWidth + insets.trailing + insets.leading, height: 14) 40 | } 41 | 42 | override func draw(_ rect: CGRect) { 43 | super.draw(rect) 44 | 45 | guard let ctx = UIGraphicsGetCurrentContext() else { 46 | return 47 | } 48 | 49 | ctx.saveGState() 50 | ctx.textMatrix = CGAffineTransform(scaleX: 1, y: -1) 51 | 52 | // align to right 53 | ctx.textPosition = CGPoint(x: frame.width - (textWidth + insets.trailing), y: firstBaseline) 54 | CTLineDraw(ctLine, ctx) 55 | ctx.restoreGState() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Gutter/STGutterMarker.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct STGutterMarker: Equatable { 4 | /// Line number 5 | public let lineNumber: Int 6 | 7 | /// View 8 | public let view: UIView 9 | 10 | public init(lineNumber: Int, view: UIView) { 11 | self.lineNumber = lineNumber 12 | self.view = view 13 | } 14 | 15 | public init(lineNumber: Int) { 16 | self.view = MarkerView() 17 | self.lineNumber = lineNumber 18 | } 19 | } 20 | 21 | private class MarkerView: UIView { 22 | override init(frame frameRect: CGRect = .zero) { 23 | super.init(frame: frameRect) 24 | clipsToBounds = true 25 | isOpaque = false 26 | } 27 | 28 | required init?(coder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | override func draw(_ rect: CGRect) { 33 | super.draw(rect) 34 | let bezierPath = indicatorPath(size: bounds.size) 35 | UIColor.tintColor.withAlphaComponent(0.6).setFill() 36 | bezierPath.fill() 37 | } 38 | 39 | private func indicatorPath(size: CGSize, inset: CGFloat = 0, flipped: Bool = false) -> UIBezierPath { 40 | // Original dimensions from SVG 41 | let originalWidth: CGFloat = 83 42 | let originalHeight: CGFloat = 38 43 | 44 | // Calculate scale factors, accounting for inset 45 | let scaleX = (size.width - (inset * 2)) / originalWidth 46 | let scaleY = (size.height - (inset * 2)) / originalHeight 47 | 48 | let path = UIBezierPath() 49 | let height: CGFloat = originalHeight 50 | 51 | // Helper function to adjust Y coordinate if needed 52 | func y(_ value: CGFloat) -> CGFloat { 53 | return flipped ? height - value : value 54 | } 55 | 56 | // Start point and first curve 57 | path.move(to: CGPoint(x: 0, y: y(3))) 58 | path.addCurve(to: CGPoint(x: 2.97836, y: y(0)), 59 | controlPoint1: CGPoint(x: 0, y: y(1.34315)), 60 | controlPoint2: CGPoint(x: 1.3215, y: y(0))) 61 | 62 | // Line through middle points to 66,0 63 | path.addLine(to: CGPoint(x: 66, y: y(0))) 64 | 65 | // Right side curves 66 | path.addCurve(to: CGPoint(x: 73.5, y: y(3.5)), 67 | controlPoint1: CGPoint(x: 69, y: y(0)), 68 | controlPoint2: CGPoint(x: 70.8165, y: y(1.35322))) 69 | 70 | path.addCurve(to: CGPoint(x: 82.5, y: y(18.5)), 71 | controlPoint1: CGPoint(x: 76.1835, y: y(5.64678)), 72 | controlPoint2: CGPoint(x: 82.5, y: y(13.5))) 73 | 74 | path.addCurve(to: CGPoint(x: 73.5, y: y(34)), 75 | controlPoint1: CGPoint(x: 82.5, y: y(23.5)), 76 | controlPoint2: CGPoint(x: 75.5, y: y(32))) 77 | 78 | path.addCurve(to: CGPoint(x: 66, y: y(38)), 79 | controlPoint1: CGPoint(x: 71.5, y: y(36)), 80 | controlPoint2: CGPoint(x: 69, y: y(38))) 81 | 82 | // Line back through middle points 83 | path.addLine(to: CGPoint(x: 2.97836, y: y(38))) 84 | 85 | // Final curve to close the path 86 | path.addCurve(to: CGPoint(x: 0, y: y(35)), 87 | controlPoint1: CGPoint(x: 1.32151, y: y(38)), 88 | controlPoint2: CGPoint(x: 0, y: y(36.6569))) 89 | 90 | path.addLine(to: CGPoint(x: 0, y: y(3))) 91 | path.close() 92 | 93 | // Create transform 94 | let transform = CGAffineTransform.identity 95 | .scaledBy(x: scaleX, y: scaleY) 96 | .translatedBy(x: inset / scaleX, y: inset / scaleY) 97 | 98 | // Apply the transform 99 | path.apply(transform) 100 | 101 | return path 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Logger.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import OSLog 6 | 7 | internal let logger = Logger(subsystem: "best.swift.sttextview", category: "STTextView") 8 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Overlays/STContentView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | final class STContentView: UIView { 7 | 8 | override class var layerClass: AnyClass { 9 | CATiledLayer.self 10 | } 11 | 12 | override init(frame: CGRect) { 13 | super.init(frame: frame) 14 | isOpaque = false 15 | isUserInteractionEnabled = false 16 | } 17 | 18 | required init?(coder: NSCoder) { 19 | fatalError("init(coder:) has not been implemented") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Overlays/STLineHighlightView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | final class STLineHighlightView: UIView { 7 | 8 | override init(frame: CGRect) { 9 | super.init(frame: frame) 10 | clipsToBounds = true 11 | isOpaque = false 12 | isUserInteractionEnabled = false 13 | } 14 | 15 | @available(*, unavailable) 16 | required init?(coder: NSCoder) { 17 | fatalError("init(coder:) has not been implemented") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Plugin/Plugin.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | internal struct Plugin { 5 | let instance: any STPlugin 6 | var events: STPluginEvents? 7 | 8 | /// Whether plugin is already setup 9 | var isSetup: Bool { 10 | events != nil 11 | } 12 | } 13 | 14 | internal extension Array { 15 | var events: [STPluginEvents] { 16 | compactMap(\.events) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Plugin/STPlugin.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | @MainActor 7 | public protocol STPlugin { 8 | associatedtype Coordinator = Void 9 | typealias Context = PluginContext 10 | typealias CoordinatorContext = STPluginCoordinatorContext 11 | 12 | /// Provides an opportunity to setup plugin environment 13 | func setUp(context: any Context) 14 | 15 | /// Creates an object to coordinate with the text view. 16 | func makeCoordinator(context: CoordinatorContext) -> Self.Coordinator 17 | 18 | /// Provides an opportunity to perform cleanup after plugin is about to remove. 19 | func tearDown() 20 | } 21 | 22 | public extension STPlugin { 23 | 24 | func tearDown() { 25 | // Nothing 26 | } 27 | } 28 | 29 | public extension STPlugin where Coordinator == Void { 30 | 31 | func makeCoordinator(context: CoordinatorContext) -> Coordinator { 32 | Coordinator() 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Plugin/STPluginContext.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | 6 | @MainActor 7 | public protocol PluginContext { 8 | associatedtype Plugin: STPlugin 9 | var coordinator: Plugin.Coordinator { get } 10 | var textView: STTextView { get } 11 | var events: STPluginEvents { get } 12 | } 13 | 14 | public struct STPluginContext: PluginContext { 15 | public let coordinator: Plugin.Coordinator 16 | public let textView: STTextView 17 | public let events: STPluginEvents 18 | 19 | init(coordinator: Plugin.Coordinator, textView: STTextView, events: STPluginEvents) { 20 | self.coordinator = coordinator 21 | self.textView = textView 22 | self.events = events 23 | } 24 | } 25 | 26 | public struct STPluginCoordinatorContext { 27 | public let textView: STTextView 28 | 29 | init(textView: STTextView) { 30 | self.textView = textView 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/Plugin/STPluginEvents.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import UIKit 6 | 7 | public class STPluginEvents { 8 | 9 | var willChangeTextHandler: ((_ affectedRange: NSTextRange) -> Void)? 10 | var didChangeTextHandler: ((_ affectedRange: NSTextRange, _ replacementString: String?) -> Void)? 11 | var shouldChangeTextHandler: ((_ affectedCharRange: NSTextRange, _ replacementString: String?) -> Bool)? 12 | var onContextMenuHandler: ((_ location: NSTextLocation, _ contentManager: NSTextContentManager) -> UIMenu)? 13 | var didLayoutViewportHandler: ((_ visibleRange: NSTextRange?) -> Void)? 14 | 15 | @discardableResult 16 | public func onWillChangeText(_ handler: @escaping (_ affectedRange: NSTextRange) -> Void) -> Self { 17 | willChangeTextHandler = handler 18 | return self 19 | } 20 | 21 | @discardableResult 22 | public func onDidChangeText(_ handler: @escaping (_ affectedRange: NSTextRange, _ replacementString: String?) -> Void) -> Self { 23 | didChangeTextHandler = handler 24 | return self 25 | } 26 | 27 | 28 | @discardableResult 29 | public func shouldChangeText(_ handler: @escaping (_ affectedCharRange: NSTextRange, _ replacementString: String?) -> Bool) -> Self { 30 | shouldChangeTextHandler = handler 31 | return self 32 | } 33 | 34 | @discardableResult 35 | public func onContextMenu(_ handler: @escaping (_ location: NSTextLocation, _ contentManager: NSTextContentManager) -> UIMenu) -> Self { 36 | onContextMenuHandler = handler 37 | return self 38 | } 39 | 40 | @discardableResult 41 | public func onDidLayoutViewport(_ handler: @escaping (_ visibleRange: NSTextRange?) -> Void) -> Self { 42 | didLayoutViewportHandler = handler 43 | return self 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextLocation.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | internal class STTextLocation: UITextPosition { 7 | let location: NSTextLocation 8 | 9 | override var debugDescription: String { 10 | location.description 11 | } 12 | 13 | init(location: NSTextLocation) { 14 | self.location = location 15 | } 16 | } 17 | 18 | internal extension NSTextLocation { 19 | var uiTextPosition: STTextLocation { 20 | STTextLocation(location: self) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextLocationRange.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | internal class STTextLocationRange: UITextRange { 7 | let textRange: NSTextRange 8 | 9 | init(textRange: NSTextRange) { 10 | self.textRange = textRange 11 | } 12 | 13 | override var debugDescription: String { 14 | textRange.description 15 | } 16 | 17 | override var start: UITextPosition { 18 | textRange.location.uiTextPosition 19 | } 20 | 21 | override var end: UITextPosition { 22 | textRange.endLocation.uiTextPosition 23 | } 24 | 25 | override var isEmpty: Bool { 26 | textRange.isEmpty 27 | } 28 | } 29 | 30 | internal extension NSTextRange { 31 | var uiTextRange: STTextLocationRange { 32 | STTextLocationRange(textRange: self) 33 | } 34 | } 35 | 36 | internal extension UITextRange { 37 | var nsTextRange: NSTextRange { 38 | guard let range = self as? STTextLocationRange else { 39 | fatalError("Invalid type") 40 | } 41 | return range.textRange 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextSelectionRect.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | final class STTextSelectionRect: UITextSelectionRect { 7 | override var rect: CGRect { 8 | _rect 9 | } 10 | 11 | override var writingDirection: NSWritingDirection { 12 | _writingDirection 13 | } 14 | 15 | override var containsStart: Bool { 16 | _containsStart 17 | } 18 | 19 | override var containsEnd: Bool { 20 | _containsEnd 21 | } 22 | 23 | override var isVertical: Bool { 24 | _isVertical 25 | } 26 | 27 | private let _rect: CGRect 28 | private let _writingDirection: NSWritingDirection 29 | private let _containsStart: Bool 30 | private let _containsEnd: Bool 31 | private let _isVertical: Bool 32 | 33 | init(rect: CGRect, writingDirection: NSWritingDirection, containsStart: Bool, containsEnd: Bool, isVertical: Bool = false) { 34 | _rect = rect 35 | _writingDirection = writingDirection 36 | _containsStart = containsStart 37 | _containsEnd = containsEnd 38 | _isVertical = isVertical 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextView+NSTextLayoutManagerDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | extension STTextView: NSTextLayoutManagerDelegate { 7 | 8 | public func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement) -> NSTextLayoutFragment { 9 | STTextLayoutFragment( 10 | textElement: textElement, 11 | range: textElement.elementRange, 12 | paragraphStyle: _defaultTypingAttributes[.paragraphStyle] as? NSParagraphStyle ?? .default 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextView+NSTextViewportLayoutControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | import STTextKitPlus 6 | 7 | extension STTextView: NSTextViewportLayoutControllerDelegate { 8 | 9 | public func viewportBounds(for textViewportLayoutController: NSTextViewportLayoutController) -> CGRect { 10 | bounds.inset(dy: -64) 11 | } 12 | 13 | public func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) { 14 | // TODO: update difference, not all layers 15 | for subview in contentView.subviews.filter({ $0 is STTextLayoutFragmentView }) { 16 | subview.removeFromSuperview() 17 | } 18 | } 19 | 20 | public func textViewportLayoutController(_ textViewportLayoutController: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) { 21 | let fragmentView = fragmentViewMap.object(forKey: textLayoutFragment) ?? STTextLayoutFragmentView(layoutFragment: textLayoutFragment, frame: textLayoutFragment.layoutFragmentFrame) 22 | // Adjust position 23 | if !fragmentView.frame.isAlmostEqual(to: textLayoutFragment.layoutFragmentFrame) { 24 | fragmentView.frame = textLayoutFragment.layoutFragmentFrame 25 | fragmentView.setNeedsLayout() 26 | fragmentView.setNeedsDisplay() 27 | } 28 | 29 | if let textLayoutFragment = textLayoutFragment as? STTextLayoutFragment { 30 | textLayoutFragment.showsInvisibleCharacters = showsInvisibleCharacters 31 | fragmentView.setNeedsDisplay() 32 | } 33 | 34 | contentView.addSubview(fragmentView) 35 | fragmentViewMap.setObject(fragmentView, forKey: textLayoutFragment) 36 | } 37 | 38 | public func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) { 39 | sizeToFit() 40 | updateSelectedLineHighlight() 41 | layoutGutter() 42 | // adjustViewportOffsetIfNeeded() 43 | 44 | if let viewportRange = textViewportLayoutController.viewportRange { 45 | for events in plugins.events { 46 | events.didLayoutViewportHandler?(viewportRange) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextView+UIKeyInput.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | extension STTextView: UIKeyInput { 7 | 8 | public var hasText: Bool { 9 | !textContentManager.documentRange.isEmpty 10 | } 11 | 12 | public func insertText(_ text: String) { 13 | let textRanges = textLayoutManager.textSelections.flatMap(\.textRanges) 14 | if shouldChangeText(in: textRanges, replacementString: text) { 15 | inputDelegate?.selectionWillChange(self) 16 | replaceCharacters(in: textRanges, with: text, useTypingAttributes: true, allowsTypingCoalescing: true) 17 | inputDelegate?.selectionDidChange(self) 18 | } 19 | } 20 | 21 | public func deleteBackward() { 22 | let textRanges = textLayoutManager.textSelections.flatMap { textSelection -> [NSTextRange] in 23 | textLayoutManager.textSelectionNavigation.deletionRanges( 24 | for: textSelection, 25 | direction: .backward, 26 | destination: .character, 27 | allowsDecomposition: false 28 | ) 29 | } 30 | 31 | if textRanges.isEmpty || !shouldChangeText(in: textRanges, replacementString: "") { 32 | return 33 | } 34 | 35 | inputDelegate?.selectionWillChange(self) 36 | replaceCharacters(in: textRanges, with: "", useTypingAttributes: false, allowsTypingCoalescing: true) 37 | inputDelegate?.selectionDidChange(self) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextView+UITextInputTraits.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | extension STTextView: UITextInputTraits { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextView+UITextInteractionDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | extension STTextView: UITextInteractionDelegate { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextView+Undo.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import UIKit 6 | 7 | // NSResponder.undoManager doesn't work out of the box (as 03.2022, macOS 12.3) 8 | // see https://gist.github.com/krzyzanowskim/1a13f27e6b469ca2ffcf9b53588b837a 9 | 10 | extension STTextView { 11 | 12 | open override var undoManager: UndoManager? { 13 | guard allowsUndo else { 14 | return nil 15 | } 16 | 17 | return delegateProxy.undoManager(for: self) ?? _undoManager 18 | } 19 | 20 | @objc func undo(_ sender: AnyObject?) { 21 | if allowsUndo { 22 | undoManager?.undo() 23 | } 24 | } 25 | 26 | @objc func redo(_ sender: AnyObject?) { 27 | if allowsUndo { 28 | undoManager?.redo() 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import UIKit 6 | 7 | /// A set of optional methods that text view delegates can use to manage selection, 8 | /// set text attributes and more. 9 | public protocol STTextViewDelegate: AnyObject { 10 | /// Returns the undo manager for the specified text view. 11 | /// 12 | /// This method provides the flexibility to return a custom undo manager for the text view. 13 | /// Although STTextView implements undo and redo for changes to text, 14 | /// applications may need a custom undo manager to handle interactions between changes 15 | /// to text and changes to other items in the application. 16 | func undoManager(for textView: STTextView) -> UndoManager? 17 | 18 | /// Any keyDown or paste which changes the contents causes this 19 | func textViewWillChangeText(_ notification: Notification) 20 | 21 | /// Informs the delegate that the text object has changed its characters or formatting attributes. 22 | func textViewDidChangeText(_ notification: Notification) 23 | 24 | /// Sent when the selection changes in the text view. 25 | /// 26 | /// You can use the selectedRange property of the text view to get the new selection. 27 | func textViewDidChangeSelection(_ notification: Notification) 28 | 29 | /// Sent when a text view needs to determine if text in a specified range should be changed. 30 | func textView(_ textView: STTextView, shouldChangeTextIn affectedCharRange: NSTextRange, replacementString: String?) -> Bool 31 | 32 | /// Sent when a text view will change text. 33 | func textView(_ textView: STTextView, willChangeTextIn affectedCharRange: NSTextRange, replacementString: String) 34 | 35 | /// Sent when a text view did change text. 36 | func textView(_ textView: STTextView, didChangeTextIn affectedCharRange: NSTextRange, replacementString: String) 37 | 38 | // MARK: Clicking and Pasting 39 | 40 | /// Sent after the user clicks a link. 41 | /// - Parameters: 42 | /// - textView: The text view sending the message. 43 | /// - link: The link that was clicked; the value of link is either URL or String. 44 | /// - location: The location where the click occurred. 45 | /// - Returns: true if the click was handled; otherwise, false to allow the next responder to handle it. 46 | func textView(_ textView: STTextView, clickedOnLink link: Any, at location: any NSTextLocation) -> Bool 47 | } 48 | 49 | // MARK: - Default implementation 50 | 51 | public extension STTextViewDelegate { 52 | 53 | func undoManager(for textView: STTextView) -> UndoManager? { 54 | nil 55 | } 56 | 57 | func textViewWillChangeText(_ notification: Notification) { 58 | // 59 | } 60 | 61 | func textViewDidChangeText(_ notification: Notification) { 62 | // 63 | } 64 | 65 | func textViewDidChangeSelection(_ notification: Notification) { 66 | // 67 | } 68 | 69 | func textView(_ textView: STTextView, shouldChangeTextIn affectedCharRange: NSTextRange, replacementString: String?) -> Bool { 70 | true 71 | } 72 | 73 | func textView(_ textView: STTextView, willChangeTextIn affectedCharRange: NSTextRange, replacementString: String) { 74 | 75 | } 76 | 77 | func textView(_ textView: STTextView, didChangeTextIn affectedCharRange: NSTextRange, replacementString: String) { 78 | 79 | } 80 | 81 | func textView(_ textView: STTextView, clickedOnLink link: Any, at location: any NSTextLocation) -> Bool { 82 | false 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/STTextViewDelegateProxy.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import Foundation 5 | import UIKit 6 | 7 | class STTextViewDelegateProxy: NSObject, STTextViewDelegate { 8 | weak var source: (any STTextViewDelegate)? 9 | 10 | init(source: STTextViewDelegate?) { 11 | self.source = source 12 | } 13 | 14 | func undoManager(for textView: STTextView) -> UndoManager? { 15 | source?.undoManager(for: textView) 16 | } 17 | 18 | func textViewWillChangeText(_ notification: Notification) { 19 | source?.textViewWillChangeText(notification) 20 | } 21 | 22 | func textViewDidChangeText(_ notification: Notification) { 23 | source?.textViewDidChangeText(notification) 24 | } 25 | 26 | func textViewDidChangeSelection(_ notification: Notification) { 27 | source?.textViewDidChangeSelection(notification) 28 | } 29 | 30 | func textView(_ textView: STTextView, shouldChangeTextIn affectedCharRange: NSTextRange, replacementString: String?) -> Bool { 31 | var result = source?.textView(textView, shouldChangeTextIn: affectedCharRange, replacementString: replacementString) ?? true 32 | result = result && textView.plugins.events.reduce(result) { partialResult, events in 33 | partialResult && events.shouldChangeTextHandler?(affectedCharRange, replacementString) ?? true 34 | } 35 | return result 36 | } 37 | 38 | func textView(_ textView: STTextView, willChangeTextIn affectedCharRange: NSTextRange, replacementString: String) { 39 | source?.textView(textView, willChangeTextIn: affectedCharRange, replacementString: replacementString) 40 | 41 | for events in textView.plugins.events { 42 | events.willChangeTextHandler?(affectedCharRange) 43 | } 44 | } 45 | 46 | func textView(_ textView: STTextView, didChangeTextIn affectedCharRange: NSTextRange, replacementString: String) { 47 | source?.textView(textView, didChangeTextIn: affectedCharRange, replacementString: replacementString) 48 | 49 | for events in textView.plugins.events { 50 | events.didChangeTextHandler?(affectedCharRange, replacementString) 51 | } 52 | 53 | } 54 | 55 | func textView(_ textView: STTextView, clickedOnLink link: Any, at location: any NSTextLocation) -> Bool { 56 | source?.textView(textView, clickedOnLink: link, at: location) ?? false 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Sources/STTextViewUIKit/UITextDirection+Conversion.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | extension UITextDirection { 7 | var textSelectionNavigationDirection: NSTextSelectionNavigation.Direction { 8 | switch self.rawValue { 9 | case UITextLayoutDirection.right.rawValue: 10 | .right 11 | case UITextLayoutDirection.left.rawValue: 12 | .left 13 | case UITextLayoutDirection.up.rawValue: 14 | .up 15 | case UITextLayoutDirection.down.rawValue: 16 | .down 17 | case UITextStorageDirection.forward.rawValue: 18 | .forward 19 | case UITextStorageDirection.backward.rawValue: 20 | .backward 21 | default: 22 | NSTextSelectionNavigation.Direction(rawValue: self.rawValue)! 23 | } 24 | } 25 | } 26 | 27 | extension UITextLayoutDirection { 28 | var textSelectionNavigationDirection: NSTextSelectionNavigation.Direction { 29 | switch self { 30 | case .up: 31 | .up 32 | case .down: 33 | .down 34 | case .left: 35 | .left 36 | case .right: 37 | .right 38 | @unknown default: 39 | NSTextSelectionNavigation.Direction(rawValue: self.rawValue)! 40 | } 41 | } 42 | } 43 | 44 | extension UITextStorageDirection { 45 | var textSelectionNavigationDirection: NSTextSelectionNavigation.Direction { 46 | switch self { 47 | case .forward: 48 | .forward 49 | case .backward: 50 | .backward 51 | @unknown default: 52 | NSTextSelectionNavigation.Direction(rawValue: self.rawValue)! 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Tests/STTextViewAppKitTests/ContentTests.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import XCTest 3 | @testable import STTextViewAppKit 4 | 5 | class ContentTests : XCTestCase { 6 | 7 | func testContentUpdate() { 8 | let textView = STTextView() 9 | XCTAssertTrue(textView.text!.isEmpty) 10 | XCTAssertEqual(textView.attributedString().length, 0) 11 | 12 | textView.text = "1234" 13 | XCTAssertEqual(textView.text!.count, 4) 14 | XCTAssertEqual(textView.attributedString().length, 4) 15 | 16 | textView.text = "5678" 17 | XCTAssertEqual(textView.text!.count, 4) 18 | XCTAssertEqual(textView.attributedString().length, 4) 19 | 20 | textView.attributedText = NSAttributedString(string: "12345") 21 | XCTAssertEqual(textView.text!.count, 5) 22 | XCTAssertEqual(textView.attributedString().length, 5) 23 | 24 | textView.attributedText = NSAttributedString(string: "6789") 25 | XCTAssertEqual(textView.text!.count, 4) 26 | XCTAssertEqual(textView.attributedString().length, 4) 27 | 28 | textView.text = "" 29 | XCTAssertEqual(textView.text!.count, 0) 30 | XCTAssertEqual(textView.attributedString().length, 0) 31 | } 32 | 33 | func testContentUpdateStringAfterAttributedString() { 34 | let textView = STTextView() 35 | textView.attributedText = NSAttributedString(string: "1234") 36 | textView.text = "" 37 | XCTAssertEqual(textView.text!.count, 0) 38 | XCTAssertEqual(textView.attributedString().length, 0) 39 | } 40 | 41 | func testFontChange() { 42 | let textView = STTextView() 43 | XCTAssertNotNil(textView.font) 44 | XCTAssertNotNil(textView.typingAttributes[.font]) 45 | 46 | textView.font = NSFont.systemFont(ofSize: 24) 47 | XCTAssertNotNil(textView.font) 48 | XCTAssertEqual(textView.font, NSFont.systemFont(ofSize: 24)) 49 | XCTAssertEqual(textView.typingAttributes[.font] as! NSFont, NSFont.systemFont(ofSize: 24)) 50 | 51 | textView.font = NSFont.systemFont(ofSize: 96) 52 | XCTAssertNotNil(textView.font) 53 | XCTAssertEqual(textView.font, NSFont.systemFont(ofSize: 96)) 54 | XCTAssertEqual(textView.typingAttributes[.font] as! NSFont, NSFont.systemFont(ofSize: 96)) 55 | 56 | XCTAssertNotNil(textView.font) 57 | XCTAssertNotNil(textView.typingAttributes[.font]) 58 | } 59 | } 60 | #endif 61 | -------------------------------------------------------------------------------- /Tests/STTextViewAppKitTests/TextViewTests.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import XCTest 3 | @testable import STTextViewAppKit 4 | 5 | class TextViewTests : XCTestCase { 6 | 7 | func testInitialSelection() { 8 | let nstv = NSTextView() 9 | let sttv = STTextView() 10 | 11 | XCTAssertEqual(nstv.selectedRange(), sttv.textSelection) 12 | } 13 | 14 | } 15 | 16 | #endif 17 | -------------------------------------------------------------------------------- /Tests/STTextViewAppKitTests/UndoTests.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import XCTest 3 | @testable import STTextViewAppKit 4 | 5 | final class UndoTests: XCTestCase { 6 | func testInsertingAtEndAndUndo() { 7 | let textView = STTextView() 8 | textView.insertText("a") 9 | textView.insertText("b") 10 | 11 | textView.undo(nil) 12 | XCTAssertEqual(textView.text!, "a") 13 | XCTAssertEqual(textView.selectedRange(), NSRange(location: 1, length: 0)) 14 | } 15 | 16 | func testPasteLongerThanCurrentContentUndo() { 17 | let textView = STTextView() 18 | textView.text! = "first line\nsecond line" 19 | textView.setSelectedRange(NSRange(location: 11, length: 11)) 20 | NSPasteboard.general.clearContents() 21 | NSPasteboard.general.setString("new second line\nthird line", forType: .string) 22 | 23 | textView.paste(nil) 24 | XCTAssertEqual(textView.text!, "first line\nnew second line\nthird line") 25 | 26 | textView.undo(nil) 27 | XCTAssertEqual(textView.text!, "first line\nsecond line") 28 | textView.setSelectedRange(NSRange(location: 11, length: 11)) 29 | } 30 | 31 | func testInsertBetweenAndUndo() { 32 | let textView = STTextView() 33 | textView.insertText("123456789") 34 | textView.setSelectedRange(NSRange(location: 3, length: 3)) 35 | 36 | textView.insertText("a") 37 | XCTAssertEqual(textView.text!, "123a789") 38 | 39 | textView.undo(nil) 40 | XCTAssertEqual(textView.selectedRange(), NSRange(location: 3, length: 3)) 41 | XCTAssertEqual(textView.text!, "123456789") 42 | } 43 | 44 | func testTypingCoalescing() throws { 45 | let textView = STTextView() 46 | textView.keyDown(with: try .create(characters: "a")) 47 | textView.keyDown(with: try .create(characters: "b")) 48 | textView.keyDown(with: try .create(key: .return)) 49 | textView.keyDown(with: try .create(characters: "c")) 50 | textView.keyDown(with: try .create(characters: "d")) 51 | XCTAssertEqual(textView.text!, "ab\ncd") 52 | 53 | textView.undo(nil) 54 | XCTAssertEqual(textView.text!, "ab\n") 55 | 56 | textView.undo(nil) 57 | XCTAssertEqual(textView.text!, "ab") 58 | 59 | textView.undo(nil) 60 | XCTAssertEqual(textView.text!, "") 61 | } 62 | 63 | func testRedo() { 64 | let textView = STTextView() 65 | textView.insertText("123456789") 66 | textView.setSelectedRange(NSRange(location: 3, length: 3)) 67 | 68 | textView.insertText("a") 69 | XCTAssertEqual(textView.text!, "123a789") 70 | 71 | textView.undo(nil) 72 | XCTAssertEqual(textView.text!, "123456789") 73 | 74 | textView.undo(nil) 75 | XCTAssertEqual(textView.text!, "") 76 | 77 | textView.redo(nil) 78 | XCTAssertEqual(textView.text!, "123456789") 79 | 80 | textView.redo(nil) 81 | XCTAssertEqual(textView.text!, "123a789") 82 | } 83 | } 84 | #endif 85 | -------------------------------------------------------------------------------- /Tests/STTextViewUIKitTests/TextViewTests.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || targetEnvironment(macCatalyst) 2 | import XCTest 3 | @testable import STTextViewUIKit 4 | 5 | class STTextViewDelegateTests : XCTestCase { 6 | 7 | func testInitialState() { 8 | let textView = STTextView() 9 | XCTAssertNil(textView.selectedTextRange) 10 | } 11 | 12 | func testTextDelegate1() { 13 | let textView = STTextView() 14 | let willChangeTextExpecation = expectation(description: "willChangeText") 15 | let didChangeSelectionExpecation = expectation(description: "didChangeSelection") 16 | didChangeSelectionExpecation.expectedFulfillmentCount = 3 17 | let didChangeTextExpecation = expectation(description: "didChangeText") 18 | 19 | let textViewDelegate = TextViewDelegate( 20 | willChangeText: { _ in 21 | willChangeTextExpecation.fulfill() 22 | }, didChangeText: { _ in 23 | didChangeTextExpecation.fulfill() 24 | }, didChangeSelection: { _ in 25 | didChangeSelectionExpecation.fulfill() 26 | } 27 | ) 28 | 29 | textView.textDelegate = textViewDelegate 30 | textView.text = "0123456789" 31 | 32 | waitForExpectations(timeout: 1) 33 | } 34 | 35 | } 36 | 37 | 38 | private class TextViewDelegate: STTextViewDelegate { 39 | var willChangeText: (Notification) -> Void 40 | var didChangeText: (Notification) -> Void 41 | var didChangeSelection: (Notification) -> Void 42 | var shouldChangeTextIn: (_ affectedCharRange: NSTextRange, _ replacementString: String?) -> Bool 43 | var willChangeTextIn: (_ affectedCharRange: NSTextRange, _ replacementString: String) -> Void 44 | var didChangeTextIn: (_ affectedCharRange: NSTextRange, _ replacementString: String) -> Void 45 | 46 | init( 47 | willChangeText: @escaping (Notification) -> Void = { _ in }, 48 | didChangeText: @escaping (Notification) -> Void = { _ in }, 49 | didChangeSelection: @escaping (Notification) -> Void = { _ in }, 50 | shouldChangeTextIn: @escaping (_ affectedCharRange: NSTextRange, _ replacementString: String?) -> Bool = { _, _ in true }, 51 | willChangeTextIn: @escaping (_ affectedCharRange: NSTextRange, _ replacementString: String) -> Void = { _, _ in }, 52 | didChangeTextIn: @escaping (_ affectedCharRange: NSTextRange, _ replacementString: String) -> Void = { _, _ in } 53 | ) { 54 | self.willChangeText = willChangeText 55 | self.didChangeText = didChangeText 56 | self.didChangeSelection = didChangeSelection 57 | self.shouldChangeTextIn = shouldChangeTextIn 58 | self.willChangeTextIn = willChangeTextIn 59 | self.didChangeTextIn = didChangeTextIn 60 | } 61 | 62 | func textViewWillChangeText(_ notification: Notification) { 63 | willChangeText(notification) 64 | } 65 | 66 | func textViewDidChangeText(_ notification: Notification) { 67 | didChangeText(notification) 68 | } 69 | 70 | func textViewDidChangeSelection(_ notification: Notification) { 71 | didChangeSelection(notification) 72 | } 73 | 74 | func textView(_ textView: STTextView, willChangeTextIn affectedCharRange: NSTextRange, replacementString: String) { 75 | willChangeTextIn(affectedCharRange, replacementString) 76 | } 77 | 78 | func textView(_ textView: STTextView, shouldChangeTextIn affectedCharRange: NSTextRange, replacementString: String?) -> Bool { 79 | shouldChangeTextIn(affectedCharRange, replacementString) 80 | } 81 | 82 | func textView(_ textView: STTextView, didChangeTextIn affectedCharRange: NSTextRange, replacementString: String) { 83 | didChangeTextIn(affectedCharRange, replacementString) 84 | } 85 | } 86 | 87 | #endif 88 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/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 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_512x512@2x@2x 1.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "filename" : "icon_16x16.png", 11 | "idiom" : "mac", 12 | "scale" : "1x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32 1.png", 17 | "idiom" : "mac", 18 | "scale" : "2x", 19 | "size" : "16x16" 20 | }, 21 | { 22 | "filename" : "icon_32x32.png", 23 | "idiom" : "mac", 24 | "scale" : "1x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_32x32@2x@2x.png", 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "32x32" 32 | }, 33 | { 34 | "filename" : "icon_128x128.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_128x128@2x@2x 1.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "128x128" 44 | }, 45 | { 46 | "filename" : "icon_128x128@2x@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_256x256@2x@2x 1.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "256x256" 56 | }, 57 | { 58 | "filename" : "icon_256x256@2x@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "512x512" 62 | }, 63 | { 64 | "filename" : "icon_512x512@2x@2x.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "512x512" 68 | } 69 | ], 70 | "info" : { 71 | "author" : "xcode", 72 | "version" : 1 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x 1.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x 1.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_32x32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_32x32 1.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x 1.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit.SwiftUI/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/ContentView.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import SwiftUI 5 | import STTextViewSwiftUI 6 | 7 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 8 | typealias Font = NSFont 9 | typealias Color = NSColor 10 | let textColor = Color.textColor 11 | #endif 12 | #if canImport(UIKit) 13 | typealias Font = UIFont 14 | typealias Color = UIColor 15 | let textColor = Color.label 16 | #endif 17 | 18 | struct ContentView: View { 19 | @State private var text: AttributedString = "" 20 | @State private var selection: NSRange? 21 | @State private var counter = 0 22 | @State private var font = Font.monospacedSystemFont(ofSize: 0, weight: .medium) 23 | 24 | var body: some View { 25 | VStack(spacing: 0) { 26 | // this is fast 27 | TextView( 28 | text: $text, 29 | selection: $selection, 30 | options: [.wrapLines, .highlightSelectedLine, .showLineNumbers] 31 | ) 32 | .textViewFont(font) 33 | 34 | // Button("Modify") { 35 | // text.insert(AttributedString("\(counter)\n"), at: text.startIndex) 36 | // counter += 1 37 | // selection = NSRange(location: 0, length: 3) 38 | // } 39 | 40 | // SwiftUI is slow, I wouldn't use it 41 | // 42 | // SwiftUI.TextEditor(text: Binding(get: { String(text.characters) }, set: { text = AttributedString($0) })) 43 | // .font(.body) 44 | 45 | HStack { 46 | if let selection { 47 | Text("Location: \(selection.location)") 48 | } else { 49 | Text("No selection") 50 | } 51 | 52 | Spacer() 53 | } 54 | .padding(.vertical, 4) 55 | .padding(.horizontal, 8) 56 | } 57 | .onAppear { 58 | loadContent() 59 | } 60 | } 61 | 62 | private func loadContent() { 63 | let string = try! String(contentsOf: Bundle.main.url(forResource: "content", withExtension: "txt")!) 64 | self.text = AttributedString( 65 | string.prefix(4096), 66 | attributes: AttributeContainer([.foregroundColor: textColor, .font: font]) 67 | ) 68 | } 69 | } 70 | 71 | struct ContentView_Previews: PreviewProvider { 72 | static var previews: some View { 73 | ContentView() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | LSItemContentTypes 11 | 12 | com.example.plain-text 13 | 14 | NSUbiquitousDocumentUserActivityType 15 | $(PRODUCT_BUNDLE_IDENTIFIER).example-document 16 | 17 | 18 | UTImportedTypeDeclarations 19 | 20 | 21 | UTTypeConformsTo 22 | 23 | public.plain-text 24 | 25 | UTTypeDescription 26 | Example Text 27 | UTTypeIdentifier 28 | com.example.plain-text 29 | UTTypeTagSpecification 30 | 31 | public.filename-extension 32 | 33 | exampletext 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/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 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/TextEditUI.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /TextEdit.SwiftUI/TextEditUIApp.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import SwiftUI 5 | 6 | @main 7 | struct TextEditUIApp: App { 8 | var body: some Scene { 9 | WindowGroup("Text Editor (SwiftUI)", id: "main") { 10 | ContentView() 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /TextEdit.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /TextEdit.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TextEdit/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 | -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_512x512@2x@2x 1.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "filename" : "icon_16x16.png", 11 | "idiom" : "mac", 12 | "scale" : "1x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32 1.png", 17 | "idiom" : "mac", 18 | "scale" : "2x", 19 | "size" : "16x16" 20 | }, 21 | { 22 | "filename" : "icon_32x32.png", 23 | "idiom" : "mac", 24 | "scale" : "1x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_32x32@2x@2x.png", 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "32x32" 32 | }, 33 | { 34 | "filename" : "icon_128x128.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_128x128@2x@2x 1.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "128x128" 44 | }, 45 | { 46 | "filename" : "icon_128x128@2x@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_256x256@2x@2x 1.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "256x256" 56 | }, 57 | { 58 | "filename" : "icon_256x256@2x@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "512x512" 62 | }, 63 | { 64 | "filename" : "icon_512x512@2x@2x.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "512x512" 68 | } 69 | ], 70 | "info" : { 71 | "author" : "xcode", 72 | "version" : 1 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x 1.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x 1.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_32x32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_32x32 1.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x 1.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krzyzanowskim/STTextView/34507187eb63bff44fd7fd2643eb1ef8fd5e070b/TextEdit/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png -------------------------------------------------------------------------------- /TextEdit/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TextEdit/Mac/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import AppKit 5 | 6 | @main 7 | class AppDelegate: NSObject, NSApplicationDelegate { 8 | 9 | func applicationDidFinishLaunching(_ aNotification: Notification) { 10 | // Insert code here to initialize your application 11 | } 12 | 13 | func applicationWillTerminate(_ aNotification: Notification) { 14 | // Insert code here to tear down your application 15 | } 16 | 17 | func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { 18 | return true 19 | } 20 | 21 | 22 | } 23 | 24 | -------------------------------------------------------------------------------- /TextEdit/Mac/CompletionItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import STTextView 4 | 5 | enum Completion { 6 | 7 | struct Item: STCompletionItem { 8 | let id: String 9 | let label: String 10 | let symbolName: String 11 | let insertText: String 12 | 13 | var view: NSView { 14 | NSHostingView(rootView: ItemView(label: label, symbolName: symbolName)) 15 | } 16 | } 17 | 18 | } 19 | 20 | private struct ItemView: View { 21 | @Environment(\.colorScheme) var colorScheme 22 | 23 | let label: String 24 | let symbolName: String 25 | 26 | var body: some View { 27 | VStack(alignment: .leading) { 28 | HStack { 29 | Image(systemName: symbolName) 30 | .symbolRenderingMode(.multicolor) 31 | .resizable() 32 | .aspectRatio(contentMode: .fit) 33 | .foregroundStyle(Color.accentColor, Color(nsColor: .secondaryLabelColor)) 34 | .frame(maxWidth: 12) 35 | .cornerRadius(2) 36 | 37 | Text(label) 38 | .fontWeight(.regular) 39 | .foregroundStyle(Color(nsColor: .secondaryLabelColor)) 40 | 41 | Spacer() 42 | } 43 | .monospacedDigit() 44 | .padding(.horizontal, 6) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /TextEdit/Mac/Tokenizer.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import NaturalLanguage 5 | 6 | class Tokenizer { 7 | 8 | struct Word: CustomStringConvertible { 9 | let string: String 10 | 11 | var description: String { 12 | string 13 | } 14 | } 15 | 16 | static func words(_ string: String, maxCount: Int = 512) -> AsyncStream { 17 | let tokenizer = NLTokenizer(unit: .word) 18 | tokenizer.string = string 19 | 20 | return AsyncStream { continuation in 21 | var count = 0 22 | tokenizer.enumerateTokens(in: string.startIndex.. maxCount { 30 | continuation.finish() 31 | return false 32 | } 33 | 34 | return !Task.isCancelled 35 | } 36 | 37 | continuation.finish() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /TextEdit/TextEdit.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /TextEdit/TextEdit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TextEdit/TextEdit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TextEdit/TextEdit.xcodeproj/xcshareddata/xcschemes/TextEdit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 | 64 | 65 | 69 | 70 | 71 | 72 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /TextEdit/iOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | @main 7 | class AppDelegate: UIResponder, UIApplicationDelegate { 8 | 9 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 10 | // Override point for customization after application launch. 11 | return true 12 | } 13 | 14 | // MARK: UISceneSession Lifecycle 15 | 16 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 17 | // Called when a new scene session is being created. 18 | // Use this method to select a configuration to create the new scene with. 19 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 20 | } 21 | 22 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 23 | // Called when the user discards a scene session. 24 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 25 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /TextEdit/iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | Main 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /TextEdit/iOS/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 | -------------------------------------------------------------------------------- /TextEdit/iOS/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Marcin Krzyzanowski 2 | // https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md 3 | 4 | import UIKit 5 | 6 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 7 | 8 | var window: UIWindow? 9 | 10 | 11 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 12 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 13 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 14 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 15 | guard let _ = (scene as? UIWindowScene) else { return } 16 | } 17 | 18 | func sceneDidDisconnect(_ scene: UIScene) { 19 | // Called as the scene is being released by the system. 20 | // This occurs shortly after the scene enters the background, or when its session is discarded. 21 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 22 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 23 | } 24 | 25 | func sceneDidBecomeActive(_ scene: UIScene) { 26 | // Called when the scene has moved from an inactive state to an active state. 27 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 28 | } 29 | 30 | func sceneWillResignActive(_ scene: UIScene) { 31 | // Called when the scene will move from an active state to an inactive state. 32 | // This may occur due to temporary interruptions (ex. an incoming phone call). 33 | } 34 | 35 | func sceneWillEnterForeground(_ scene: UIScene) { 36 | // Called as the scene transitions from the background to the foreground. 37 | // Use this method to undo the changes made on entering the background. 38 | } 39 | 40 | func sceneDidEnterBackground(_ scene: UIScene) { 41 | // Called as the scene transitions from the foreground to the background. 42 | // Use this method to save data, release shared resources, and store enough scene-specific state information 43 | // to restore the scene back to its current state. 44 | } 45 | 46 | 47 | } 48 | 49 | -------------------------------------------------------------------------------- /TextEdit/iOS/en.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 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # 2 | # git-cliff -o CHANGELOG.md 3 | # 4 | # git-cliff ~ default configuration file 5 | # https://git-cliff.org/docs/configuration 6 | # 7 | # Lines starting with "#" are comments. 8 | # Configuration options are organized into tables and keys. 9 | # See documentation for more information on available options. 10 | 11 | [changelog] 12 | # A Tera template to be rendered as the changelog's header. 13 | # See https://keats.github.io/tera/docs/#introduction 14 | header = """ 15 | # Changelog\n 16 | """ 17 | # A Tera template to be rendered for each release in the changelog. 18 | # See https://keats.github.io/tera/docs/#introduction 19 | body = """ 20 | {%- macro remote_url() -%} 21 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 22 | {%- endmacro -%} 23 | 24 | {% if version -%} 25 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 26 | {% else -%} 27 | ## [Unreleased] 28 | {% endif -%} 29 | 30 | {% for group, commits in commits | group_by(attribute="group") %} 31 | ### {{ group | upper_first }} 32 | {%- for commit in commits %} 33 | - {{ commit.message | split(pat="\n") | first | upper_first | trim }}\ 34 | {% if commit.remote.pr_number %} in \ 35 | [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ 36 | {%- endif -%} 37 | {% endfor %} 38 | {% endfor %} 39 | 40 | """ 41 | # A Tera template to be rendered as the changelog's footer. 42 | # See https://keats.github.io/tera/docs/#introduction 43 | # footer = """ 44 | # """ 45 | # Remove leading and trailing whitespaces from the changelog's body. 46 | trim = true 47 | 48 | [git] 49 | # Parse commits according to the conventional commits specification. 50 | # See https://www.conventionalcommits.org 51 | conventional_commits = true 52 | # Exclude commits that do not match the conventional commits specification. 53 | filter_unconventional = false 54 | # An array of regex based parsers to modify commit messages prior to further processing. 55 | commit_preprocessors = [ 56 | # Remove issue numbers. 57 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 58 | ] 59 | # An array of regex based parsers for extracting data from the commit message. 60 | # Assigns commits to groups. 61 | # Optionally sets the commit's scope and can decide to exclude commits from further processing. 62 | commit_parsers = [ 63 | { message = "^[a|A]dd", group = "Changed" }, 64 | { message = "^[s|S]upport", group = "Changed" }, 65 | { message = "^[r|R]emove", group = "Changed" }, 66 | { message = "^.*: add", group = "Changed" }, 67 | { message = "^.*: support", group = "Changed" }, 68 | { message = "^.*: remove", group = "Changed" }, 69 | { message = "^.*: delete", group = "Changed" }, 70 | { message = "^test", group = "Fixed" }, 71 | { message = "^fix", group = "Fixed" }, 72 | { message = "^.*: fix", group = "Fixed" }, 73 | { message = "^.*", group = "Changed" }, 74 | ] 75 | # Exclude commits that are not matched by any commit parser. 76 | filter_commits = false 77 | # Order releases topologically instead of chronologically. 78 | topo_order = false 79 | # Order of commits in each group/release within the changelog. 80 | # Allowed values: newest, oldest 81 | sort_commits = "newest" 82 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | "cargo:git-cliff" = "latest" 3 | "cargo:typos-cli" = "latest" 4 | --------------------------------------------------------------------------------