diff --git a/.github/scripts/tests.sh b/.github/scripts/tests.sh index c90874f48..116b6253a 100755 --- a/.github/scripts/tests.sh +++ b/.github/scripts/tests.sh @@ -16,6 +16,6 @@ export LC_CTYPE=en_US.UTF-8 set -o pipefail && arch -"${ARCH}" xcodebuild \ -scheme CodeEditTextView \ -derivedDataPath ".build" \ - -destination "platform=macos,arch=${ARCH}" \ + -destination "platform=macOS,arch=${ARCH},name=My Mac" \ -skipPackagePluginValidation \ clean test | xcpretty diff --git a/.github/workflows/build-documentation.yml b/.github/workflows/build-documentation.yml index 3b73dfdd5..fdcd1c077 100644 --- a/.github/workflows/build-documentation.yml +++ b/.github/workflows/build-documentation.yml @@ -7,20 +7,21 @@ jobs: runs-on: [self-hosted, macOS] steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build Documentation run: exec ./.github/scripts/build-docc.sh - name: Init new repo in dist folder and commit generated files run: | cd docs git init + git config http.postBuffer 524288000 git add -A git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git commit -m 'deploy' - name: Force push to destination branch - uses: ad-m/github-push-action@v0.6.0 + uses: ad-m/github-push-action@v0.8.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: docs diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj index 4a583aa28..d0ba091d5 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj @@ -8,27 +8,34 @@ /* Begin PBXBuildFile section */ 6C2265DF2D306AB7008710D7 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 6C2265DE2D306AB7008710D7 /* CodeEditTextView */; }; - 6C2265E42D306B90008710D7 /* SwiftUITextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2265E32D306B90008710D7 /* SwiftUITextView.swift */; }; - 6C2265E62D306D37008710D7 /* TextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2265E52D306D37008710D7 /* TextViewController.swift */; }; - 6CCDA29B2D306A25007CD84A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CCDA2942D306A25007CD84A /* Assets.xcassets */; }; - 6CCDA29D2D306A25007CD84A /* CodeEditTextViewExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CCDA2952D306A25007CD84A /* CodeEditTextViewExampleApp.swift */; }; - 6CCDA29E2D306A25007CD84A /* CodeEditTextViewExampleDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CCDA2962D306A25007CD84A /* CodeEditTextViewExampleDocument.swift */; }; - 6CCDA29F2D306A25007CD84A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CCDA2972D306A25007CD84A /* ContentView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 6C2265E12D306B58008710D7 /* CodeEditTextViewExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeEditTextViewExample.entitlements; sourceTree = ""; }; - 6C2265E32D306B90008710D7 /* SwiftUITextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUITextView.swift; sourceTree = ""; }; - 6C2265E52D306D37008710D7 /* TextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewController.swift; sourceTree = ""; }; 6CCDA27D2D306A1B007CD84A /* CodeEditTextViewExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeEditTextViewExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 6CCDA2942D306A25007CD84A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 6CCDA2952D306A25007CD84A /* CodeEditTextViewExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditTextViewExampleApp.swift; sourceTree = ""; }; - 6CCDA2962D306A25007CD84A /* CodeEditTextViewExampleDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditTextViewExampleDocument.swift; sourceTree = ""; }; - 6CCDA2972D306A25007CD84A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 6CCDA2982D306A25007CD84A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6CCDA2A12D306A5B007CD84A /* CodeEditTextView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = CodeEditTextView; path = ../..; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + B6654F662DF001EB003B32B8 /* Exceptions for "CodeEditTextViewExample" folder in "CodeEditTextViewExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 6CCDA27C2D306A1B007CD84A /* CodeEditTextViewExample */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B6654F5D2DF001EB003B32B8 /* CodeEditTextViewExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + B6654F662DF001EB003B32B8 /* Exceptions for "CodeEditTextViewExample" folder in "CodeEditTextViewExample" target */, + ); + path = CodeEditTextViewExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 6CCDA27A2D306A1B007CD84A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -41,29 +48,11 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 6C2265E02D306AEB008710D7 /* Documents */ = { - isa = PBXGroup; - children = ( - 6CCDA2962D306A25007CD84A /* CodeEditTextViewExampleDocument.swift */, - ); - path = Documents; - sourceTree = ""; - }; - 6C2265E22D306B69008710D7 /* Views */ = { - isa = PBXGroup; - children = ( - 6CCDA2972D306A25007CD84A /* ContentView.swift */, - 6C2265E32D306B90008710D7 /* SwiftUITextView.swift */, - 6C2265E52D306D37008710D7 /* TextViewController.swift */, - ); - path = Views; - sourceTree = ""; - }; 6CCDA2742D306A1B007CD84A = { isa = PBXGroup; children = ( 6CCDA2A12D306A5B007CD84A /* CodeEditTextView */, - 6CCDA2992D306A25007CD84A /* CodeEditTextViewExample */, + B6654F5D2DF001EB003B32B8 /* CodeEditTextViewExample */, 6CCDA2A02D306A5B007CD84A /* Frameworks */, 6CCDA27E2D306A1B007CD84A /* Products */, ); @@ -77,19 +66,6 @@ name = Products; sourceTree = ""; }; - 6CCDA2992D306A25007CD84A /* CodeEditTextViewExample */ = { - isa = PBXGroup; - children = ( - 6CCDA2952D306A25007CD84A /* CodeEditTextViewExampleApp.swift */, - 6C2265E02D306AEB008710D7 /* Documents */, - 6C2265E22D306B69008710D7 /* Views */, - 6CCDA2942D306A25007CD84A /* Assets.xcassets */, - 6CCDA2982D306A25007CD84A /* Info.plist */, - 6C2265E12D306B58008710D7 /* CodeEditTextViewExample.entitlements */, - ); - path = CodeEditTextViewExample; - sourceTree = ""; - }; 6CCDA2A02D306A5B007CD84A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -112,6 +88,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + B6654F5D2DF001EB003B32B8 /* CodeEditTextViewExample */, + ); name = CodeEditTextViewExample; packageProductDependencies = ( 6C2265DE2D306AB7008710D7 /* CodeEditTextView */, @@ -159,7 +138,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6CCDA29B2D306A25007CD84A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -170,11 +148,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6C2265E62D306D37008710D7 /* TextViewController.swift in Sources */, - 6CCDA29D2D306A25007CD84A /* CodeEditTextViewExampleApp.swift in Sources */, - 6CCDA29E2D306A25007CD84A /* CodeEditTextViewExampleDocument.swift in Sources */, - 6CCDA29F2D306A25007CD84A /* ContentView.swift in Sources */, - 6C2265E42D306B90008710D7 /* SwiftUITextView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme new file mode 100644 index 000000000..ceaa1d0a1 --- /dev/null +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-1024.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-1024.png new file mode 100644 index 000000000..86c5e4478 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-1024.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-128.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-128.png new file mode 100644 index 000000000..af8e599cc Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-128.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-16.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-16.png new file mode 100644 index 000000000..2e3297530 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-16.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-256.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-256.png new file mode 100644 index 000000000..b37995124 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-256.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-32.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-32.png new file mode 100644 index 000000000..4ba6ba21d Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-32.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-512.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-512.png new file mode 100644 index 000000000..e35cfe804 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-512.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-64.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-64.png new file mode 100644 index 000000000..e4cc199ba Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-64.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json index 230588010..6d0b8c531 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,35 +1,68 @@ { "images" : [ { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "size" : "16x16", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-1024.png", + "scale" : "2x" } ], "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } -} +} \ No newline at end of file diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift index 790a38fbb..47a86c96c 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift @@ -8,11 +8,11 @@ import SwiftUI import UniformTypeIdentifiers -struct CodeEditTextViewExampleDocument: FileDocument { - var text: String +struct CodeEditTextViewExampleDocument: FileDocument, @unchecked Sendable { + var text: NSTextStorage init(text: String = "") { - self.text = text + self.text = NSTextStorage(string: text) } static var readableContentTypes: [UTType] { @@ -25,11 +25,21 @@ struct CodeEditTextViewExampleDocument: FileDocument { guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } - text = String(bytes: data, encoding: .utf8) + text = try NSTextStorage( + data: data, + options: [.characterEncoding: NSUTF8StringEncoding, .fileType: NSAttributedString.DocumentType.plain], + documentAttributes: nil + ) } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - let data = Data(text.utf8) + let data = try text.data( + from: NSRange(location: 0, length: text.length), + documentAttributes: [ + .documentType: NSAttributedString.DocumentType.plain, + .characterEncoding: NSUTF8StringEncoding + ] + ) return .init(regularFileWithContents: data) } } diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift index c6b0f4f0f..9cd2a6021 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift @@ -11,15 +11,29 @@ struct ContentView: View { @Binding var document: CodeEditTextViewExampleDocument @AppStorage("wraplines") private var wrapLines: Bool = true @AppStorage("edgeinsets") private var enableEdgeInsets: Bool = false + @AppStorage("usesystemcursor") private var useSystemCursor: Bool = false + @AppStorage("isselectable") private var isSelectable: Bool = true + @AppStorage("iseditable") private var isEditable: Bool = true var body: some View { - VStack(spacing: 0) { - HStack { - Toggle("Wrap Lines", isOn: $wrapLines) - Toggle("Inset Edges", isOn: $enableEdgeInsets) - } - Divider() - SwiftUITextView(text: $document.text, wrapLines: $wrapLines, enableEdgeInsets: $enableEdgeInsets) + SwiftUITextView( + text: document.text, + wrapLines: $wrapLines, + enableEdgeInsets: $enableEdgeInsets, + useSystemCursor: $useSystemCursor, + isSelectable: $isSelectable, + isEditable: $isEditable + ) + .padding(.bottom, 28) + .overlay(alignment: .bottom) { + StatusBar( + text: document.text, + wrapLines: $wrapLines, + enableEdgeInsets: $enableEdgeInsets, + useSystemCursor: $useSystemCursor, + isSelectable: $isSelectable, + isEditable: $isEditable + ) } } } diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/StatusBar.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/StatusBar.swift new file mode 100644 index 000000000..211254072 --- /dev/null +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/StatusBar.swift @@ -0,0 +1,63 @@ +// +// StatusBar.swift +// CodeEditTextViewExample +// +// Created by Austin Condiff on 6/3/25. +// + +import SwiftUI + +struct StatusBar: View { + @Environment(\.colorScheme) + var colorScheme + + var text: NSTextStorage + + @Binding var wrapLines: Bool + @Binding var enableEdgeInsets: Bool + @Binding var useSystemCursor: Bool + @Binding var isSelectable: Bool + @Binding var isEditable: Bool + + var body: some View { + HStack { + Menu { + Toggle("Wrap Lines", isOn: $wrapLines) + Toggle("Inset Edges", isOn: $enableEdgeInsets) + Toggle("Use System Cursor", isOn: $useSystemCursor) + Toggle("Selectable", isOn: $isSelectable) + Toggle("Editable", isOn: $isEditable) + } label: {} + .background { + Image(systemName: "switch.2") + .foregroundStyle(.secondary) + .font(.system(size: 13.5, weight: .regular)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: 18, alignment: .center) + Spacer() + Group { + Text("\(text.length) characters") + } + .foregroundStyle(.secondary) + } + .font(.subheadline) + .fontWeight(.medium) + .controlSize(.small) + .padding(.horizontal, 8) + .frame(height: 28) + .background(.bar) + .overlay(alignment: .top) { + VStack { + Divider() + .overlay { + if colorScheme == .dark { + Color.black + } + } + } + } + .zIndex(2) + } +} diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift index 96d5d732b..693826a39 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift @@ -10,55 +10,29 @@ import AppKit import CodeEditTextView struct SwiftUITextView: NSViewControllerRepresentable { - @Binding var text: String + var text: NSTextStorage @Binding var wrapLines: Bool @Binding var enableEdgeInsets: Bool + @Binding var useSystemCursor: Bool + @Binding var isSelectable: Bool + @Binding var isEditable: Bool func makeNSViewController(context: Context) -> TextViewController { - let controller = TextViewController(string: text) - context.coordinator.controller = controller + let controller = TextViewController(string: "") + controller.textView.setTextStorage(text) controller.wrapLines = wrapLines controller.enableEdgeInsets = enableEdgeInsets + controller.useSystemCursor = useSystemCursor + controller.isSelectable = isSelectable + controller.isEditable = isEditable return controller } func updateNSViewController(_ nsViewController: TextViewController, context: Context) { nsViewController.wrapLines = wrapLines nsViewController.enableEdgeInsets = enableEdgeInsets - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - @MainActor - public class Coordinator: NSObject { - weak var controller: TextViewController? - var text: Binding - - init(text: Binding) { - self.text = text - super.init() - - NotificationCenter.default.addObserver( - self, - selector: #selector(textViewDidChangeText(_:)), - name: TextView.textDidChangeNotification, - object: nil - ) - } - - @objc func textViewDidChangeText(_ notification: Notification) { - guard let textView = notification.object as? TextView, - let controller, - controller.textView === textView else { - return - } - text.wrappedValue = textView.string - } - - deinit { - NotificationCenter.default.removeObserver(self) - } + nsViewController.useSystemCursor = useSystemCursor + nsViewController.isSelectable = isSelectable + nsViewController.isEditable = isEditable } } diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift index a880d9731..a37704c8c 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift @@ -27,6 +27,25 @@ class TextViewController: NSViewController { textView.wrapLines = wrapLines } } + var useSystemCursor: Bool = false { + didSet { + textView.useSystemCursor = useSystemCursor + // Force cursor update by temporarily removing and re-adding the selection + if let range = textView.selectionManager.textSelections.first?.range { + textView.selectionManager.setSelectedRange(NSRange(location: range.location, length: 0)) + } + } + } + var isSelectable: Bool = true { + didSet { + textView.isSelectable = isSelectable + } + } + var isEditable: Bool = true { + didSet { + textView.isEditable = isEditable + } + } init(string: String) { textView = TextView(string: string) diff --git a/README.md b/README.md index d615c4484..8e3a36d94 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,7 @@ A text editor specialized for displaying and editing code documents. Features include basic text editing, extremely fast initial layout, support for handling large documents, customization options for code documents. ![GitHub release](https://img.shields.io/github/v/release/CodeEditApp/CodeEditTextView?color=orange&label=latest%20release&sort=semver&style=flat-square) -![Github Tests](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditTextView/tests.yml?branch=main&label=tests&style=flat-square) -![Documentation](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditTextView/build-documentation.yml?branch=main&label=docs&style=flat-square) +![Github Tests](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditTextView/CI-push.yml?branch=main&label=tests&style=flat-square) ![GitHub Repo stars](https://img.shields.io/github/stars/CodeEditApp/CodeEditTextView?style=flat-square) ![GitHub forks](https://img.shields.io/github/forks/CodeEditApp/CodeEditTextView?style=flat-square) [![Discord Badge](https://img.shields.io/discord/951544472238444645?color=5865F2&label=Discord&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/vChUXVf9Em) @@ -102,7 +101,7 @@ Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great wo - +

        CodeEdit        

diff --git a/Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift b/Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift new file mode 100644 index 000000000..7a7da3ed0 --- /dev/null +++ b/Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift @@ -0,0 +1,12 @@ +// +// CursorSelectionMode.swift +// CodeEditTextView +// +// Created by Abe Malla on 3/31/25. +// + +enum CursorSelectionMode { + case character + case word + case line +} diff --git a/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift new file mode 100644 index 000000000..0e7f44904 --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift @@ -0,0 +1,47 @@ +// +// Emphasis.swift +// CodeEditTextView +// +// Created by Khan Winter on 3/31/25. +// + +import AppKit + +/// Represents a single emphasis with its properties +public struct Emphasis: Equatable { + /// The range the emphasis applies it's style to, relative to the entire text document. + public let range: NSRange + + /// The style to apply emphasis with, handled by the ``EmphasisManager``. + public let style: EmphasisStyle + + /// Set to `true` to 'flash' the emphasis before removing it automatically after being added. + /// + /// Useful when an emphasis should be temporary and quick, like when emphasizing paired brackets in a document. + public let flash: Bool + + /// Set to `true` to style the emphasis as 'inactive'. + /// + /// When ``style`` is ``EmphasisStyle/standard``, this reduces shadows and background color. + /// For all styles, if drawing text on top of them, this uses ``EmphasisManager/getInactiveTextColor`` instead of + /// the text view's text color to render the emphasized text. + public let inactive: Bool + + /// Set to `true` if the emphasis manager should update the text view's selected range to match + /// this object's ``Emphasis/range`` value. + public let selectInDocument: Bool + + public init( + range: NSRange, + style: EmphasisStyle = .standard, + flash: Bool = false, + inactive: Bool = false, + selectInDocument: Bool = false + ) { + self.range = range + self.style = style + self.flash = flash + self.inactive = inactive + self.selectInDocument = selectInDocument + } +} diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift new file mode 100644 index 000000000..076bea2e2 --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -0,0 +1,357 @@ +// +// EmphasisManager.swift +// CodeEditTextView +// +// Created by Tom Ludwig on 05.11.24. +// + +import AppKit + +/// Manages text emphases within a text view, supporting multiple styles and groups. +/// +/// Text emphasis draws attention to a range of text, indicating importance. +/// This object may be used in a code editor to emphasize search results, or indicate +/// bracket pairs, for instance. +/// +/// This object is designed to allow for easy grouping of emphasis types. An outside +/// object is responsible for managing what emphases are visible. Because it's very +/// likely that more than one type of emphasis may occur on the document at the same +/// time, grouping allows each emphasis to be managed separately from the others by +/// each outside object without knowledge of the other's state. +public final class EmphasisManager { + /// Internal representation of a emphasis layer with its associated text layer + private struct EmphasisLayer: Equatable { + let emphasis: Emphasis + let layer: CAShapeLayer + let textLayer: CATextLayer? + + func removeLayers() { + layer.removeAllAnimations() + layer.removeFromSuperlayer() + textLayer?.removeAllAnimations() + textLayer?.removeFromSuperlayer() + } + } + + private var emphasisGroups: [String: [EmphasisLayer]] = [:] + private let activeColor: NSColor = .findHighlightColor + private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) + private var originalSelectionColor: NSColor? + + weak var textView: TextView? + + init(textView: TextView) { + self.textView = textView + } + + // MARK: - Add, Update, Remove + + /// Adds a single emphasis to the specified group. + /// - Parameters: + /// - emphasis: The emphasis to add + /// - id: A group identifier + public func addEmphasis(_ emphasis: Emphasis, for id: String) { + addEmphases([emphasis], for: id) + } + + /// Adds multiple emphases to the specified group. + /// - Parameters: + /// - emphases: The emphases to add + /// - id: The group identifier + public func addEmphases(_ emphases: [Emphasis], for id: String) { + // Store the current selection background color if not already stored + if originalSelectionColor == nil { + originalSelectionColor = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor + } + + let layers = emphases.map { createEmphasisLayer(for: $0) } + emphasisGroups[id, default: []].append(contentsOf: layers) + // Handle selections + handleSelections(for: emphases) + + // Handle flash animations + for flashingLayer in emphasisGroups[id, default: []].filter({ $0.emphasis.flash }) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + self.applyFadeOutAnimation(to: flashingLayer.layer, textLayer: flashingLayer.textLayer) { + // Remove the emphasis from the group if it still exists + guard let emphasisIdx = self.emphasisGroups[id, default: []].firstIndex( + where: { $0 == flashingLayer } + ) else { + return + } + + self.emphasisGroups[id, default: []][emphasisIdx].removeLayers() + self.emphasisGroups[id, default: []].remove(at: emphasisIdx) + + if self.emphasisGroups[id, default: []].isEmpty { + self.emphasisGroups.removeValue(forKey: id) + } + } + } + } + } + + /// Replaces all emphases in the specified group. + /// - Parameters: + /// - emphases: The new emphases + /// - id: The group identifier + public func replaceEmphases(_ emphases: [Emphasis], for id: String) { + removeEmphases(for: id) + addEmphases(emphases, for: id) + } + + /// Updates the emphases for a group by transforming the existing array. + /// - Parameters: + /// - id: The group identifier + /// - transform: The transformation to apply to the existing emphases + public func updateEmphases(for id: String, _ transform: ([Emphasis]) -> [Emphasis]) { + let existingEmphases = emphasisGroups[id, default: []].map { $0.emphasis } + let newEmphases = transform(existingEmphases) + replaceEmphases(newEmphases, for: id) + } + + /// Removes all emphases for the given group. + /// - Parameter id: The group identifier + public func removeEmphases(for id: String) { + emphasisGroups[id]?.forEach { emphasis in + emphasis.removeLayers() + } + emphasisGroups[id] = nil + + textView?.layer?.layoutIfNeeded() + } + + /// Removes all emphases for all groups. + public func removeAllEmphases() { + emphasisGroups.keys.forEach { removeEmphases(for: $0) } + emphasisGroups.removeAll() + + // Restore original selection emphasizing + if let originalColor = originalSelectionColor { + textView?.selectionManager.selectionBackgroundColor = originalColor + } + originalSelectionColor = nil + } + + /// Gets all emphases for a given group. + /// - Parameter id: The group identifier + /// - Returns: Array of emphases in the group + public func getEmphases(for id: String) -> [Emphasis] { + emphasisGroups[id, default: []].map(\.emphasis) + } + + // MARK: - Drawing Layers + + /// Updates the positions and bounds of all emphasis layers to match the current text layout. + public func updateLayerBackgrounds() { + for emphasis in emphasisGroups.flatMap(\.value) { + guard let shapePath = makeShapePath( + forStyle: emphasis.emphasis.style, + range: emphasis.emphasis.range + ) else { + continue + } + if #available(macOS 14.0, *) { + emphasis.layer.path = shapePath.cgPath + } else { + emphasis.layer.path = shapePath.cgPathFallback + } + + // Update bounds and position + if let cgPath = emphasis.layer.path { + let boundingBox = cgPath.boundingBox + emphasis.layer.bounds = boundingBox + emphasis.layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY) + } + + // Update text layer if it exists + if let textLayer = emphasis.textLayer { + var bounds = shapePath.bounds + bounds.origin.y += 1 // Move down by 1 pixel + textLayer.frame = bounds + } + } + } + + private func createEmphasisLayer(for emphasis: Emphasis) -> EmphasisLayer { + guard let shapePath = makeShapePath(forStyle: emphasis.style, range: emphasis.range) else { + return EmphasisLayer(emphasis: emphasis, layer: CAShapeLayer(), textLayer: nil) + } + + let layer = createShapeLayer(shapePath: shapePath, emphasis: emphasis) + textView?.layer?.insertSublayer(layer, at: 1) + + let textLayer = createTextLayer(for: emphasis) + if let textLayer = textLayer { + textView?.layer?.addSublayer(textLayer) + } + + if emphasis.inactive == false && emphasis.style == .standard { + applyPopAnimation(to: layer) + } + + return EmphasisLayer(emphasis: emphasis, layer: layer, textLayer: textLayer) + } + + private func makeShapePath(forStyle emphasisStyle: EmphasisStyle, range: NSRange) -> NSBezierPath? { + switch emphasisStyle { + case .standard, .outline: + return textView?.layoutManager.roundedPathForRange(range, cornerRadius: emphasisStyle.shapeRadius) + case .underline: + guard let layoutManager = textView?.layoutManager else { + return nil + } + let lineHeight = layoutManager.estimateLineHeight() + let lineBottomPadding = (lineHeight - (lineHeight / layoutManager.lineHeightMultiplier)) / 4 + let path = NSBezierPath() + for rect in layoutManager.rectsFor(range: range) { + path.move(to: NSPoint(x: rect.minX, y: rect.maxY - lineBottomPadding)) + path.line(to: NSPoint(x: rect.maxX, y: rect.maxY - lineBottomPadding)) + } + return path + } + } + + private func createShapeLayer(shapePath: NSBezierPath, emphasis: Emphasis) -> CAShapeLayer { + let layer = CAShapeLayer() + + switch emphasis.style { + case .standard: + layer.cornerRadius = 4.0 + layer.fillColor = (emphasis.inactive ? inactiveColor : activeColor).cgColor + layer.shadowColor = .black + layer.shadowOpacity = emphasis.inactive ? 0.0 : 0.5 + layer.shadowOffset = CGSize(width: 0, height: 1.5) + layer.shadowRadius = 1.5 + layer.opacity = 1.0 + layer.zPosition = emphasis.inactive ? 0 : 1 + case .underline(let color): + layer.lineWidth = 1.0 + layer.lineCap = .round + layer.strokeColor = color.cgColor + layer.fillColor = nil + layer.opacity = emphasis.flash ? 0.0 : 1.0 + layer.zPosition = 1 + case let .outline(color, shouldFill): + layer.cornerRadius = 2.5 + layer.borderColor = color.cgColor + layer.borderWidth = 0.5 + layer.fillColor = shouldFill ? color.cgColor : nil + layer.opacity = emphasis.flash ? 0.0 : 1.0 + layer.zPosition = 1 + } + + if #available(macOS 14.0, *) { + layer.path = shapePath.cgPath + } else { + layer.path = shapePath.cgPathFallback + } + + // Set bounds of the layer; needed for the scale animation + if let cgPath = layer.path { + let boundingBox = cgPath.boundingBox + layer.bounds = boundingBox + layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY) + } + + return layer + } + + private func createTextLayer(for emphasis: Emphasis) -> CATextLayer? { + guard let textView = textView, + let layoutManager = textView.layoutManager, + let shapePath = layoutManager.roundedPathForRange(emphasis.range), + let originalString = textView.textStorage?.attributedSubstring(from: emphasis.range) else { + return nil + } + + var bounds = shapePath.bounds + bounds.origin.y += 1 // Move down by 1 pixel + + // Create text layer + let textLayer = CATextLayer() + textLayer.frame = bounds + textLayer.backgroundColor = NSColor.clear.cgColor + textLayer.contentsScale = textView.window?.screen?.backingScaleFactor ?? 2.0 + textLayer.allowsFontSubpixelQuantization = true + textLayer.zPosition = 2 + + // Get the font from the attributed string + if let font = originalString.attribute(.font, at: 0, effectiveRange: nil) as? NSFont { + textLayer.font = font + } else { + textLayer.font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + } + + updateTextLayer(textLayer, with: originalString, emphasis: emphasis) + return textLayer + } + + private func updateTextLayer( + _ textLayer: CATextLayer, + with originalString: NSAttributedString, + emphasis: Emphasis + ) { + let text = NSMutableAttributedString(attributedString: originalString) + text.addAttribute( + .foregroundColor, + value: emphasis.inactive ? getInactiveTextColor() : NSColor.black, + range: NSRange(location: 0, length: text.length) + ) + textLayer.string = text + } + + private func getInactiveTextColor() -> NSColor { + if textView?.effectiveAppearance.name == .darkAqua { + return .white + } + return .black + } + + // MARK: - Animations + + private func applyPopAnimation(to layer: CALayer) { + let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") + scaleAnimation.values = [1.0, 1.25, 1.0] + scaleAnimation.keyTimes = [0, 0.3, 1] + scaleAnimation.duration = 0.1 + scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)] + + layer.add(scaleAnimation, forKey: "popAnimation") + } + + private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?, completion: @escaping () -> Void) { + let fadeAnimation = CABasicAnimation(keyPath: "opacity") + fadeAnimation.fromValue = 1.0 + fadeAnimation.toValue = 0.0 + fadeAnimation.duration = 0.1 + fadeAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + fadeAnimation.fillMode = .forwards + fadeAnimation.isRemovedOnCompletion = false + + layer.add(fadeAnimation, forKey: "fadeOutAnimation") + + if let textLayer = textLayer, let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation { + textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") + textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") + } + + // Remove both layers after animation completes + DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { + layer.removeFromSuperlayer() + textLayer?.removeFromSuperlayer() + completion() + } + } + + /// Handles selection of text ranges for emphases where select is true + private func handleSelections(for emphases: [Emphasis]) { + let selectableRanges = emphases.filter(\.selectInDocument).map(\.range) + guard let textView, !selectableRanges.isEmpty else { return } + + textView.selectionManager.setSelectedRanges(selectableRanges) + textView.scrollSelectionToVisible() + textView.needsDisplay = true + } +} diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift new file mode 100644 index 000000000..66b32a862 --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift @@ -0,0 +1,42 @@ +// +// EmphasisStyle.swift +// CodeEditTextView +// +// Created by Khan Winter on 3/31/25. +// + +import AppKit + +/// Defines the style of emphasis to apply to text ranges +public enum EmphasisStyle: Equatable { + /// Standard emphasis with background color + case standard + /// Underline emphasis with a line color + case underline(color: NSColor) + /// Outline emphasis with a border color + case outline(color: NSColor, fill: Bool = false) + + public static func == (lhs: EmphasisStyle, rhs: EmphasisStyle) -> Bool { + switch (lhs, rhs) { + case (.standard, .standard): + return true + case (.underline(let lhsColor), .underline(let rhsColor)): + return lhsColor == rhsColor + case let (.outline(lhsColor, lhsFill), .outline(rhsColor, rhsFill)): + return lhsColor == rhsColor && lhsFill == rhsFill + default: + return false + } + } + + var shapeRadius: CGFloat { + switch self { + case .standard: + 4 + case .underline: + 0 + case .outline: + 2.5 + } + } +} diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift deleted file mode 100644 index 0ed8a5716..000000000 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// EmphasizeAPI.swift -// CodeEditTextView -// -// Created by Tom Ludwig on 05.11.24. -// - -import AppKit - -/// Emphasizes text ranges within a given text view. -public class EmphasizeAPI { - // MARK: - Properties - - public private(set) var emphasizedRanges: [EmphasizedRange] = [] - public private(set) var emphasizedRangeIndex: Int? - private let activeColor: NSColor = NSColor(hex: 0xFFFB00, alpha: 1) - private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) - - weak var textView: TextView? - - init(textView: TextView) { - self.textView = textView - } - - // MARK: - Structs - public struct EmphasizedRange { - public var range: NSRange - var layer: CAShapeLayer - } - - // MARK: - Public Methods - - /// Emphasises multiple ranges, with one optionally marked as active (highlighted usually in yellow). - /// - /// - Parameters: - /// - ranges: An array of ranges to highlight. - /// - activeIndex: The index of the range to highlight in yellow. Defaults to `nil`. - /// - clearPrevious: Removes previous emphasised ranges. Defaults to `true`. - public func emphasizeRanges(ranges: [NSRange], activeIndex: Int? = nil, clearPrevious: Bool = true) { - if clearPrevious { - removeEmphasizeLayers() // Clear all existing highlights - } - - ranges.enumerated().forEach { index, range in - let isActive = (index == activeIndex) - emphasizeRange(range: range, active: isActive) - - if isActive { - emphasizedRangeIndex = activeIndex - } - } - } - - /// Emphasises a single range. - /// - Parameters: - /// - range: The text range to highlight. - /// - active: Whether the range should be highlighted as active (usually in yellow). Defaults to `false`. - public func emphasizeRange(range: NSRange, active: Bool = false) { - guard let shapePath = textView?.layoutManager?.roundedPathForRange(range) else { return } - - let layer = createEmphasizeLayer(shapePath: shapePath, active: active) - textView?.layer?.insertSublayer(layer, at: 1) - - emphasizedRanges.append(EmphasizedRange(range: range, layer: layer)) - } - - /// Removes the highlight for a specific range. - /// - Parameter range: The range to remove. - public func removeHighlightForRange(_ range: NSRange) { - guard let index = emphasizedRanges.firstIndex(where: { $0.range == range }) else { return } - - let removedLayer = emphasizedRanges[index].layer - removedLayer.removeFromSuperlayer() - - emphasizedRanges.remove(at: index) - - // Adjust the active highlight index - if let currentIndex = emphasizedRangeIndex { - if currentIndex == index { - // TODO: What is the desired behaviour here? - emphasizedRangeIndex = nil // Reset if the active highlight is removed - } else if currentIndex > index { - emphasizedRangeIndex = currentIndex - 1 // Shift if the removed index was before the active index - } - } - } - - /// Highlights the previous emphasised range (usually in yellow). - /// - /// - Returns: An optional `NSRange` representing the newly active emphasized range. - /// Returns `nil` if there are no prior ranges to highlight. - @discardableResult - public func highlightPrevious() -> NSRange? { - return shiftActiveHighlight(amount: -1) - } - - /// Highlights the next emphasised range (usually in yellow). - /// - /// - Returns: An optional `NSRange` representing the newly active emphasized range. - /// Returns `nil` if there are no subsequent ranges to highlight. - @discardableResult - public func highlightNext() -> NSRange? { - return shiftActiveHighlight(amount: 1) - } - - /// Removes all emphasised ranges. - public func removeEmphasizeLayers() { - emphasizedRanges.forEach { $0.layer.removeFromSuperlayer() } - emphasizedRanges.removeAll() - emphasizedRangeIndex = nil - } - - // MARK: - Private Methods - - private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer { - let layer = CAShapeLayer() - layer.cornerRadius = 3.0 - layer.fillColor = (active ? activeColor : inactiveColor).cgColor - layer.shadowColor = .black - layer.shadowOpacity = active ? 0.3 : 0.0 - layer.shadowOffset = CGSize(width: 0, height: 1) - layer.shadowRadius = 3.0 - layer.opacity = 1.0 - - if #available(macOS 14.0, *) { - layer.path = shapePath.cgPath - } else { - layer.path = shapePath.cgPathFallback - } - - // Set bounds of the layer; needed for the scale animation - if let cgPath = layer.path { - let boundingBox = cgPath.boundingBox - layer.bounds = boundingBox - layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY) - } - - return layer - } - - /// Shifts the active highlight to a different emphasized range based on the specified offset. - /// - /// - Parameter amount: The offset to shift the active highlight. - /// - A positive value moves to subsequent ranges. - /// - A negative value moves to prior ranges. - /// - /// - Returns: An optional `NSRange` representing the newly active highlight, colored in the active color. - /// Returns `nil` if no change occurred (e.g., if there are no highlighted ranges). - private func shiftActiveHighlight(amount: Int) -> NSRange? { - guard !emphasizedRanges.isEmpty else { return nil } - - var currentIndex = emphasizedRangeIndex ?? -1 - currentIndex = (currentIndex + amount + emphasizedRanges.count) % emphasizedRanges.count - - guard currentIndex < emphasizedRanges.count else { return nil } - - // Reset the previously active layer - if let currentIndex = emphasizedRangeIndex { - let previousLayer = emphasizedRanges[currentIndex].layer - previousLayer.fillColor = inactiveColor.cgColor - previousLayer.shadowOpacity = 0.0 - } - - // Set the new active layer - let newLayer = emphasizedRanges[currentIndex].layer - newLayer.fillColor = activeColor.cgColor - newLayer.shadowOpacity = 0.3 - - applyPopAnimation(to: newLayer) - emphasizedRangeIndex = currentIndex - - return emphasizedRanges[currentIndex].range - } - - private func applyPopAnimation(to layer: CALayer) { - let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") - scaleAnimation.values = [1.0, 1.5, 1.0] - scaleAnimation.keyTimes = [0, 0.3, 1] - scaleAnimation.duration = 0.2 - scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)] - - layer.add(scaleAnimation, forKey: "popAnimation") - } -} diff --git a/Sources/CodeEditTextView/Extensions/CGRectArray+BoundingRect.swift b/Sources/CodeEditTextView/Extensions/CGRectArray+BoundingRect.swift new file mode 100644 index 000000000..5f94249fa --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/CGRectArray+BoundingRect.swift @@ -0,0 +1,23 @@ +// +// File.swift +// CodeEditTextView +// +// Created by Khan Winter on 7/17/25. +// + +import AppKit + +extension Array where Element == CGRect { + /// Returns a rect object that contains all of the rects in this array. + /// Returns `.zero` if the array is empty. + /// - Returns: The minimum rectangle that contains all rectangles in this array. + func boundingRect() -> CGRect { + guard !self.isEmpty else { return .zero } + let minX = self.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0 + let minY = self.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0 + let max = self.max(by: { $0.maxY < $1.maxY }) ?? .zero + let origin = CGPoint(x: minX, y: minY) + let size = CGSize(width: max.maxX - minX, height: max.maxY - minY) + return CGRect(origin: origin, size: size) + } +} diff --git a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift new file mode 100644 index 000000000..fefe98530 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift @@ -0,0 +1,127 @@ +// +// CTTypesetter+SuggestLineBreak.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import AppKit + +extension CTTypesetter { + /// Suggest a line break for the given line break strategy. + /// - Parameters: + /// - typesetter: The typesetter to use. + /// - strategy: The strategy that determines a valid line break. + /// - startingOffset: Where to start breaking. + /// - constrainingWidth: The available space for the line. + /// - Returns: An offset relative to the entire string indicating where to break. + func suggestLineBreak( + using string: NSAttributedString, + strategy: LineBreakStrategy, + subrange: NSRange, + constrainingWidth: CGFloat + ) -> Int { + switch strategy { + case .character: + return suggestLineBreakForCharacter( + string: string, + startingOffset: subrange.location, + constrainingWidth: constrainingWidth + ) + case .word: + return suggestLineBreakForWord( + string: string, + subrange: subrange, + constrainingWidth: constrainingWidth + ) + } + } + + /// Suggest a line break for the character break strategy. + /// - Parameters: + /// - typesetter: The typesetter to use. + /// - startingOffset: Where to start breaking. + /// - constrainingWidth: The available space for the line. + /// - Returns: An offset relative to the entire string indicating where to break. + private func suggestLineBreakForCharacter( + string: NSAttributedString, + startingOffset: Int, + constrainingWidth: CGFloat + ) -> Int { + var breakIndex: Int + // Check if we need to skip to an attachment + + breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth) + guard breakIndex < string.length else { + return breakIndex + } + let substring = string.attributedSubstring(from: NSRange(location: breakIndex - 1, length: 2)).string + if substring == LineEnding.carriageReturnLineFeed.rawValue { + // Breaking in the middle of the clrf line ending + breakIndex += 1 + } + + return breakIndex + } + + /// Suggest a line break for the word break strategy. + /// - Parameters: + /// - typesetter: The typesetter to use. + /// - startingOffset: Where to start breaking. + /// - constrainingWidth: The available space for the line. + /// - Returns: An offset relative to the entire string indicating where to break. + private func suggestLineBreakForWord( + string: NSAttributedString, + subrange: NSRange, + constrainingWidth: CGFloat + ) -> Int { + var breakIndex = subrange.location + CTTypesetterSuggestClusterBreak(self, subrange.location, constrainingWidth) + let isBreakAtEndOfString = breakIndex >= subrange.max + + let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string) + if isNextCharacterCarriageReturn { + breakIndex += 1 + } + + let canLastCharacterBreak = (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1, for: string)) + + if isBreakAtEndOfString || canLastCharacterBreak { + // Breaking either at the end of the string, or on a whitespace. + return breakIndex + } else if breakIndex - 1 > 0 { + // Try to walk backwards until we hit a whitespace or punctuation + var index = breakIndex - 1 + + while breakIndex - index < 100 && index > subrange.location { + if ensureCharacterCanBreakLine(at: index, for: string) { + return index + 1 + } + index -= 1 + } + } + + return breakIndex + } + + /// Ensures the character at the given index can break a line. + /// - Parameter index: The index to check at. + /// - Returns: True, if the character is a whitespace or punctuation character. + private func ensureCharacterCanBreakLine(at index: Int, for string: NSAttributedString) -> Bool { + let subrange = (string.string as NSString).rangeOfComposedCharacterSequence(at: index) + let set = CharacterSet(charactersIn: (string.string as NSString).substring(with: subrange)) + return set.isSubset(of: .whitespacesAndNewlines) || set.isSubset(of: .punctuationCharacters) + } + + /// Check if the break index is on a CRLF (`\r\n`) character, indicating a valid break position. + /// - Parameter breakIndex: The index to check in the string. + /// - Returns: True, if the break index lies after the `\n` character in a `\r\n` sequence. + private func checkIfLineBreakOnCRLF(_ breakIndex: Int, for string: NSAttributedString) -> Bool { + guard breakIndex - 1 > 0 && breakIndex + 1 <= string.length else { + return false + } + let substringRange = NSRange(location: breakIndex - 1, length: 2) + let substring = string.attributedSubstring(from: substringRange).string + + return substring == LineEnding.carriageReturnLineFeed.rawValue + } +} diff --git a/Sources/CodeEditTextView/Extensions/CharacterSet.swift b/Sources/CodeEditTextView/Extensions/CharacterSet.swift new file mode 100644 index 000000000..0d59bc970 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/CharacterSet.swift @@ -0,0 +1,14 @@ +// +// CharacterSet.swift +// CodeEditTextView +// +// Created by Abe Malla on 3/29/25. +// + +import Foundation + +extension CharacterSet { + /// Returns a character set containing the characters common in code names + static let codeIdentifierCharacters: CharacterSet = .alphanumerics + .union(.init(charactersIn: "_")) +} diff --git a/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift index dcb1a7121..a174185d6 100644 --- a/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift +++ b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift @@ -24,7 +24,7 @@ extension NSBezierPath { path.addCurve(to: points[2], control1: points[0], control2: points[1]) case .closePath: path.closeSubpath() - @unknown default: + default: continue } } diff --git a/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift b/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift index 114652f61..4a09a64ef 100644 --- a/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift +++ b/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift @@ -60,19 +60,13 @@ extension NSBezierPath { let distance1 = sqrt(vector1.x * vector1.x + vector1.y * vector1.y) let distance2 = sqrt(vector2.x * vector2.x + vector2.y * vector2.y) - // TODO: Check if .zero should get used or just skipped - if distance1.isZero || distance2.isZero { continue } + if distance1.isZero || distance2.isZero { + // Dividing by 0 will result in `NaN` points. + continue + } let unitVector1 = distance1 > 0 ? NSPoint(x: vector1.x / distance1, y: vector1.y / distance1) : NSPoint.zero let unitVector2 = distance2 > 0 ? NSPoint(x: vector2.x / distance2, y: vector2.y / distance2) : NSPoint.zero - // This uses the dot product formula: cos(θ) = (u1 • u2), - // where u1 and u2 are unit vectors. The result will range from -1 to 1: - let angleCosine = unitVector1.x * unitVector2.x + unitVector1.y * unitVector2.y - - // If the cosine of the angle is less than 0.5 (i.e., angle > ~60 degrees), - // the radius is reduced to half to avoid overlapping or excessive smoothing. - let clampedRadius = angleCosine < 0.5 ? radius /** 0.5 */: radius // Adjust for sharp angles - // Calculate the corner start and end let cornerStart = NSPoint(x: p1.x - unitVector1.x * radius, y: p1.y - unitVector1.y * radius) let cornerEnd = NSPoint(x: p1.x + unitVector2.x * radius, y: p1.y + unitVector2.y * radius) @@ -95,6 +89,14 @@ extension NSBezierPath { // Calculate the vectors and unit vectors let finalVector = NSPoint(x: firstPoint.x - lastPoint.x, y: firstPoint.y - lastPoint.y) let distance = sqrt(finalVector.x * finalVector.x + finalVector.y * finalVector.y) + + // Dividing by 0 after this will cause an assertion failure. Something went wrong with the given points + // this could mean we're rounding a 0-width and 0-height rect. + guard distance != 0 else { + path.line(to: lastPoint) + return path + } + let unitVector = NSPoint(x: finalVector.x / distance, y: finalVector.y / distance) // Calculate the final corner start and initial corner end diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift index a9dc211a2..0c8c8e6a0 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift @@ -7,7 +7,7 @@ import Foundation -extension NSRange { +public extension NSRange { @inline(__always) init(start: Int, end: Int) { self.init(location: start, length: end - start) diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift new file mode 100644 index 000000000..40f6251cc --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift @@ -0,0 +1,14 @@ +// +// NSRange+translate.swift +// CodeEditTextView +// +// Created by Khan Winter on 7/21/25. +// + +import Foundation + +extension NSRange { + func translate(location: Int) -> NSRange { + NSRange(location: self.location + location, length: length) + } +} diff --git a/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift new file mode 100644 index 000000000..9b7fa9d75 --- /dev/null +++ b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift @@ -0,0 +1,20 @@ +// +// InvisibleCharactersConfig.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/9/25. +// + +import Foundation +import AppKit + +public enum InvisibleCharacterStyle: Hashable { + case replace(replacementCharacter: String, color: NSColor, font: NSFont) + case emphasize(color: NSColor) +} + +public protocol InvisibleCharactersDelegate: AnyObject { + var triggerCharacters: Set { get } + func invisibleStyleShouldClearCache() -> Bool + func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? +} diff --git a/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift new file mode 100644 index 000000000..3cfcdbafd --- /dev/null +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift @@ -0,0 +1,15 @@ +// +// MarkedRanges.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/17/25. +// + +import AppKit + +/// Struct for passing attribute and range information easily down into line fragments, typesetters without +/// requiring a reference to the marked text manager. +public struct MarkedRanges { + let ranges: [NSRange] + let attributes: [NSAttributedString.Key: Any] +} diff --git a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift index 9c7612913..5270dce8d 100644 --- a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift @@ -9,13 +9,6 @@ import AppKit /// Manages marked ranges. Not a public API. class MarkedTextManager { - /// Struct for passing attribute and range information easily down into line fragments, typesetters w/o - /// requiring a reference to the marked text manager. - struct MarkedRanges { - let ranges: [NSRange] - let attributes: [NSAttributedString.Key: Any] - } - /// All marked ranges being tracked. private(set) var markedRanges: [NSRange] = [] diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift new file mode 100644 index 000000000..e1f4363d0 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -0,0 +1,50 @@ +// +// TextAttachment.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import AppKit + +public enum TextAttachmentAction { + /// Perform no action. + case none + /// Replace the attachment range with the given string. + case replace(text: String) + /// Discard the attachment and perform no other action, this is the default action. + case discard +} + +/// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view. +public protocol TextAttachment: AnyObject { + var width: CGFloat { get } + var isSelected: Bool { get set } + + func draw(in context: CGContext, rect: NSRect) + + /// The action that should be performed when this attachment is invoked (double-click, enter pressed). + /// This method is optional, by default the attachment is discarded. + func attachmentAction() -> TextAttachmentAction +} + +public extension TextAttachment { + func attachmentAction() -> TextAttachmentAction { .discard } +} + +/// Type-erasing type for ``TextAttachment`` that also contains range information about the attachment. +/// +/// This type cannot be initialized outside of `CodeEditTextView`, but will be received when interrogating +/// the ``TextAttachmentManager``. +public struct AnyTextAttachment: Equatable { + package(set) public var range: NSRange + public let attachment: any TextAttachment + + var width: CGFloat { + attachment.width + } + + public static func == (_ lhs: AnyTextAttachment, _ rhs: AnyTextAttachment) -> Bool { + lhs.range == rhs.range && lhs.attachment === rhs.attachment + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift new file mode 100644 index 000000000..7b4d0c0e5 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -0,0 +1,222 @@ +// +// TextAttachmentManager.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import Foundation + +/// Manages a set of attachments for the layout manager, provides methods for efficiently finding attachments for a +/// line range. +/// +/// If two attachments are overlapping, the one placed further along in the document will be +/// ignored when laying out attachments. +public final class TextAttachmentManager { + private var orderedAttachments: [AnyTextAttachment] = [] + weak var layoutManager: TextLayoutManager? + private var selectionObserver: (any NSObjectProtocol)? + + public weak var delegate: TextAttachmentManagerDelegate? + + /// Adds a new attachment, keeping `orderedAttachments` sorted by range.location. + /// If two attachments overlap, the layout phase will later ignore the one with the higher start. + /// - Complexity: `O(n log(n))` due to array insertion. Could be improved with a binary tree. + public func add(_ attachment: any TextAttachment, for range: NSRange) { + let attachment = AnyTextAttachment(range: range, attachment: attachment) + let insertIndex = findInsertionIndex(for: range.location) + orderedAttachments.insert(attachment, at: insertIndex) + + // This is ugly, but if our attachment meets the end of the next line, we need to merge that line with this + // one. + var getNextOne = false + layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach { + if $0.height != 0 { + layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) + } + + // Only do this if it's not the end of the document + if range.max == $0.range.max && range.max != layoutManager?.lineStorage.length { + getNextOne = true + } + } + + if getNextOne, + let trailingLine = layoutManager?.lineStorage.getLine(atOffset: range.max), + trailingLine.height != 0 { + // Update the one trailing line. + layoutManager?.lineStorage.update(atOffset: range.max, delta: 0, deltaHeight: -trailingLine.height) + } + + layoutManager?.setNeedsLayout() + + delegate?.textAttachmentDidAdd(attachment.attachment, for: range) + } + + /// Removes an attachment and invalidates layout for the removed range. + /// - Parameter offset: The offset the attachment begins at. + /// - Returns: The removed attachment, if it exists. + @discardableResult + public func remove(atOffset offset: Int) -> AnyTextAttachment? { + let index = findInsertionIndex(for: offset) + + guard index < orderedAttachments.count && orderedAttachments[index].range.location == offset else { + return nil + } + + let attachment = orderedAttachments.remove(at: index) + layoutManager?.invalidateLayoutForRange(attachment.range) + + delegate?.textAttachmentDidRemove(attachment.attachment, for: attachment.range) + + return attachment + } + + /// Finds attachments starting in the given line range, and returns them as an array. + /// Returned attachment's ranges will be relative to the _document_, not the line. + /// - Complexity: `O(n log(n))`, ideally `O(log(n))` + public func getAttachmentsStartingIn(_ range: NSRange) -> [AnyTextAttachment] { + var results: [AnyTextAttachment] = [] + var idx = findInsertionIndex(for: range.location) + while idx < orderedAttachments.count { + let attachment = orderedAttachments[idx] + let loc = attachment.range.location + if loc >= range.upperBound { + break + } + if range.contains(loc) { + if let lastResult = results.last, !lastResult.range.contains(attachment.range.location) { + results.append(attachment) + } else if results.isEmpty { + results.append(attachment) + } + } + idx += 1 + } + return results + } + + /// Returns all attachments whose ranges overlap the given query range. + /// + /// - Parameter range: The `NSRange` to test for overlap. + /// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`. + public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] { + // Find the first attachment whose end is beyond the start of the query. + guard let startIdx = orderedAttachments.firstIndex(where: { $0.range.upperBound >= range.location }) else { + return [] + } + + var results: [AnyTextAttachment] = [] + var idx = startIdx + + // Collect every subsequent attachment that truly overlaps the query. + while idx < orderedAttachments.count { + let attachment = orderedAttachments[idx] + if attachment.range.location >= range.upperBound { + break + } + if (attachment.range.intersection(range)?.length ?? 0 > 0 || attachment.range.max == range.location) + && results.last?.range != attachment.range { + results.append(attachment) + } + idx += 1 + } + + return results + } + + /// Updates the text attachments to stay in the same relative spot after the edit, and removes any attachments that + /// were in the updated range. + /// - Parameters: + /// - atOffset: The offset text was updated at. + /// - delta: The change delta, positive is an insertion. + package func textUpdated(atOffset: Int, delta: Int) { + for (idx, attachment) in orderedAttachments.enumerated().reversed() { + if attachment.range.contains(atOffset) { + orderedAttachments.remove(at: idx) + } else if attachment.range.location > atOffset { + orderedAttachments[idx].range.location += delta + } + } + } + + /// Set up the attachment manager to listen to selection updates, giving text attachments a chance to respond to + /// selection state. + /// + /// This is specifically not in the initializer to prevent a bit of a chicken-and-the-egg situation where the + /// layout manager and selection manager need each other to init. + /// + /// - Parameter selectionManager: The selection manager to listen to. + func setUpSelectionListener(for selectionManager: TextSelectionManager) { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + + selectionObserver = NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: selectionManager, + queue: .main + ) { [weak self] notification in + guard let selectionManager = notification.object as? TextSelectionManager else { + return + } + let selectedSet = IndexSet(ranges: selectionManager.textSelections.map({ $0.range })) + for attachment in self?.orderedAttachments ?? [] { + let isSelected = selectedSet.contains(integersIn: attachment.range) + if attachment.attachment.isSelected != isSelected { + self?.layoutManager?.invalidateLayoutForRange(attachment.range) + } + attachment.attachment.isSelected = isSelected + } + } + } + + deinit { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + } +} + +private extension TextAttachmentManager { + /// Binary-searches `orderedAttachments` and returns the smallest index + /// at which `predicate(attachment)` is true (i.e. the lower-bound index). + /// + /// - Note: always returns a value in `0...orderedAttachments.count`. + /// If it returns `orderedAttachments.count`, no element satisfied + /// the predicate, but that’s still a valid insertion point. + func lowerBoundIndex( + where predicate: (AnyTextAttachment) -> Bool + ) -> Int { + var low = 0 + var high = orderedAttachments.count + while low < high { + let mid = (low + high) / 2 + if predicate(orderedAttachments[mid]) { + high = mid + } else { + low = mid + 1 + } + } + return low + } + + /// Returns the index in `orderedAttachments` at which an attachment whose + /// `range.location == location` *could* be inserted, keeping the array sorted. + /// + /// - Parameter location: the attachment’s `range.location` + /// - Returns: a valid insertion index in `0...orderedAttachments.count` + func findInsertionIndex(for location: Int) -> Int { + lowerBoundIndex { $0.range.location >= location } + } + + /// Finds the first index whose attachment satisfies `predicate`. + /// + /// - Parameter predicate: the query predicate. + /// - Returns: the first matching index, or `nil` if none of the + /// attachments satisfy the predicate. + func firstIndex(where predicate: (AnyTextAttachment) -> Bool) -> Int? { + let idx = lowerBoundIndex { predicate($0) } + return idx < orderedAttachments.count ? idx : nil + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManagerDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManagerDelegate.swift new file mode 100644 index 000000000..c5b363b00 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManagerDelegate.swift @@ -0,0 +1,13 @@ +// +// TextAttachmentManagerDelegate.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/25/25. +// + +import Foundation + +public protocol TextAttachmentManagerDelegate: AnyObject { + func textAttachmentDidAdd(_ attachment: any TextAttachment, for range: NSRange) + func textAttachmentDidRemove(_ attachment: any TextAttachment, for range: NSRange) +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index 8219bf162..b3d7d11bc 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -10,17 +10,53 @@ import AppKit // MARK: - Edits extension TextLayoutManager: NSTextStorageDelegate { - /// Notifies the layout manager of an edit. + /// Receives edit notifications from the text storage and updates internal data structures to stay in sync with + /// text content. /// - /// Used by the `TextView` to tell the layout manager about any edits that will happen. - /// Use this to keep the layout manager's line storage in sync with the text storage. + /// If the changes are only attribute changes, this method invalidates layout for the edited range and returns. /// - /// - Parameters: - /// - range: The range of the edit. - /// - string: The string to replace in the given range. - public func willReplaceCharactersInRange(range: NSRange, with string: String) { + /// Otherwise, any lines that were removed or replaced by the edit are first removed from the text line layout + /// storage. Then, any new lines are inserted into the same storage. + /// + /// For instance, if inserting a newline this method will: + /// - Remove no lines (none were replaced) + /// - Update the current line's range to contain the newline character. + /// - Insert a new line after the current line. + /// + /// If a selection containing a newline is deleted and replaced with two more newlines this method will: + /// - Delete the original line. + /// - Insert two lines. + /// + /// - Note: This method *does not* cause a layout calculation. If a method is finding `NaN` values for line + /// fragments, ensure `layout` or `ensureLayoutUntil` are called on the subject ranges. + public func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { + if editedMask.contains(.editedAttributes) && delta == 0 { + invalidateLayoutForRange(editedRange) + } + return + } + + let insertedStringRange = NSRange(location: editedRange.location, length: editedRange.length - delta) + removeLayoutLinesIn(range: insertedStringRange) + insertNewLines(for: editedRange) + + attachments.textUpdated(atOffset: editedRange.location, delta: delta) + + invalidateLayoutForRange(insertedStringRange) + } + + /// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an + /// edit. + /// - Parameter range: The range that was deleted. + private func removeLayoutLinesIn(range: NSRange) { // Loop through each line being replaced in reverse, updating and removing where necessary. - for linePosition in lineStorage.linesInRange(range).reversed() { + for linePosition in lineStorage.linesInRange(range).reversed() { // Two cases: Updated line, deleted line entirely guard let intersection = linePosition.range.intersection(range), !intersection.isEmpty else { continue } if intersection == linePosition.range && linePosition.range.max != lineStorage.length { @@ -32,31 +68,30 @@ extension TextLayoutManager: NSTextStorageDelegate { lineStorage.delete(lineAt: nextLine.range.location) let delta = -intersection.length + nextLine.range.length if delta != 0 { - lineStorage.update(atIndex: linePosition.range.location, delta: delta, deltaHeight: 0) + lineStorage.update(atOffset: linePosition.range.location, delta: delta, deltaHeight: 0) } } else { - lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0) + lineStorage.update(atOffset: linePosition.range.location, delta: -intersection.length, deltaHeight: 0) } } + } + /// Inserts any newly inserted lines into the line layout storage. Exits early if the range is empty. + /// - Parameter range: The range of the string that was inserted into the text storage. + private func insertNewLines(for range: NSRange) { + guard !range.isEmpty, let string = textStorage?.substring(from: range) as? NSString else { return } // Loop through each line being inserted, inserting & splitting where necessary - if !string.isEmpty { - var index = 0 - while let nextLine = (string as NSString).getNextLine(startingAt: index) { - let lineRange = NSRange(start: index, end: nextLine.max) - applyLineInsert((string as NSString).substring(with: lineRange) as NSString, at: range.location + index) - index = nextLine.max - } + var index = 0 + while let nextLine = string.getNextLine(startingAt: index) { + let lineRange = NSRange(start: index, end: nextLine.max) + applyLineInsert(string.substring(with: lineRange) as NSString, at: range.location + index) + index = nextLine.max + } - if index < (string as NSString).length { - // Get the last line. - applyLineInsert( - (string as NSString).substring(from: index) as NSString, - at: range.location + index - ) - } + if index < string.length { + // Get the last line. + applyLineInsert(string.substring(from: index) as NSString, at: range.location + index) } - setNeedsLayout() } /// Applies a line insert to the internal line storage tree. @@ -65,10 +100,10 @@ extension TextLayoutManager: NSTextStorageDelegate { /// - location: The location the string is being inserted into. private func applyLineInsert(_ insertedString: NSString, at location: Int) { if LineEnding(line: insertedString as String) != nil { - if location == textStorage?.length ?? 0 { + if location == lineStorage.length { // Insert a new line at the end of the document, need to insert a new line 'cause there's nothing to // split. Also, append the new text to the last line. - lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) + lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0) lineStorage.insert( line: TextLine(), atOffset: location + insertedString.length, @@ -82,7 +117,7 @@ extension TextLayoutManager: NSTextStorageDelegate { let splitLength = linePosition.range.max - location let lineDelta = insertedString.length - splitLength // The difference in the line being edited if lineDelta != 0 { - lineStorage.update(atIndex: location, delta: lineDelta, deltaHeight: 0.0) + lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0) } lineStorage.insert( @@ -93,21 +128,7 @@ extension TextLayoutManager: NSTextStorageDelegate { ) } } else { - lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) - } - } - - /// This method is to simplify keeping the layout manager in sync with attribute changes in the storage object. - /// This does not handle cases where characters have been inserted or removed from the storage. - /// For that, see the `willPerformEdit` method. - public func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - if editedMask.contains(.editedAttributes) && delta == 0 { - invalidateLayoutForRange(editedRange) + lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0) } } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift index 6ddb9a305..6b13819c6 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift @@ -14,7 +14,8 @@ extension TextLayoutManager { for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) { linePosition.data.setNeedsLayout() } - layoutLines() + + layoutView?.needsLayout = true } /// Invalidates layout for the given range of text. @@ -24,11 +25,18 @@ extension TextLayoutManager { linePosition.data.setNeedsLayout() } - layoutLines() + // Special case where we've deleted from the very end, `linesInRange` correctly does not return any lines + // So we need to invalidate the last line specifically. + if range.location == textStorage?.length, !lineStorage.isEmpty { + lineStorage.last?.data.setNeedsLayout() + } + + layoutView?.needsLayout = true } public func setNeedsLayout() { needsLayout = true visibleLineIds.removeAll(keepingCapacity: true) + layoutView?.needsLayout = true } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index ff7315270..f6a5e1ab8 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -14,14 +14,14 @@ public extension TextLayoutManager { /// if there is no delegate from `0` to the estimated document height. /// /// - Returns: An iterator to iterate through all visible lines. - func visibleLines() -> Iterator { + func visibleLines() -> YPositionIterator { let visibleRect = delegate?.visibleRect ?? NSRect( x: 0, y: 0, width: 0, height: estimatedHeight() ) - return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage) + return YPositionIterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), layoutManager: self) } /// Iterate over all lines in the y position range. @@ -29,19 +29,201 @@ public extension TextLayoutManager { /// - minY: The minimum y position to begin at. /// - maxY: The maximum y position to iterate to. /// - Returns: An iterator that will iterate through all text lines in the y position range. - func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorage.TextLineStorageYIterator { - lineStorage.linesStartingAt(minY, until: maxY) + func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> YPositionIterator { + YPositionIterator(minY: minY, maxY: maxY, layoutManager: self) + } + /// Iterate over all lines that overlap a document range. + /// - Parameters: + /// - range: The range in the document to iterate over. + /// - Returns: An iterator for lines in the range. The iterator returns lines that *overlap* with the range. + /// Returned lines may extend slightly before or after the queried range. + func linesInRange(_ range: NSRange) -> RangeIterator { + RangeIterator(range: range, layoutManager: self) + } + + /// This iterator iterates over "visible" text positions that overlap a range of vertical `y` positions + /// using ``TextLayoutManager/determineVisiblePosition(for:)``. + /// + /// Next elements are retrieved lazily. Additionally, this iterator uses a stable `index` rather than a y position + /// or a range to fetch the next line. This means the line storage can be updated during iteration. + struct YPositionIterator: LazySequenceProtocol, IteratorProtocol { + typealias TextLinePosition = TextLineStorage.TextLinePosition + + private weak var layoutManager: TextLayoutManager? + private let minY: CGFloat + private let maxY: CGFloat + private var currentPosition: (position: TextLinePosition, indexRange: ClosedRange)? + + init(minY: CGFloat, maxY: CGFloat, layoutManager: TextLayoutManager) { + self.minY = minY + self.maxY = maxY + self.layoutManager = layoutManager + } + + /// Iterates over the "visible" text positions. + /// + /// See documentation on ``TextLayoutManager/determineVisiblePosition(for:)`` for details. + public mutating func next() -> TextLineStorage.TextLinePosition? { + if let currentPosition { + guard let nextPosition = layoutManager?.lineStorage.getLine( + atIndex: currentPosition.indexRange.upperBound + 1 + ), nextPosition.yPos < maxY else { + return nil + } + self.currentPosition = layoutManager?.determineVisiblePosition(for: nextPosition) + return self.currentPosition?.position + } else if let position = layoutManager?.lineStorage.getLine(atPosition: minY) { + currentPosition = layoutManager?.determineVisiblePosition(for: position) + return currentPosition?.position + } + + return nil + } } - struct Iterator: LazySequenceProtocol, IteratorProtocol { - private var storageIterator: TextLineStorage.TextLineStorageYIterator + /// This iterator iterates over "visible" text positions that overlap a document using + /// ``TextLayoutManager/determineVisiblePosition(for:)``. + /// + /// Next elements are retrieved lazily. Additionally, this iterator uses a stable `index` rather than a y position + /// or a range to fetch the next line. This means the line storage can be updated during iteration. + struct RangeIterator: LazySequenceProtocol, IteratorProtocol { + typealias TextLinePosition = TextLineStorage.TextLinePosition - init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage) { - storageIterator = storage.linesStartingAt(minY, until: maxY) + private weak var layoutManager: TextLayoutManager? + private let range: NSRange + private var currentPosition: (position: TextLinePosition, indexRange: ClosedRange)? + + init(range: NSRange, layoutManager: TextLayoutManager) { + self.range = range + self.layoutManager = layoutManager } + /// Iterates over the "visible" text positions. + /// + /// See documentation on ``TextLayoutManager/determineVisiblePosition(for:)`` for details. public mutating func next() -> TextLineStorage.TextLinePosition? { - storageIterator.next() + if let currentPosition { + guard let nextPosition = layoutManager?.lineStorage.getLine( + atIndex: currentPosition.indexRange.upperBound + 1 + ), nextPosition.range.location < range.max else { + return nil + } + self.currentPosition = layoutManager?.determineVisiblePosition(for: nextPosition) + return self.currentPosition?.position + } else if let position = layoutManager?.lineStorage.getLine(atOffset: range.location) { + currentPosition = layoutManager?.determineVisiblePosition(for: position) + return currentPosition?.position + } + + return nil + } + } + + /// Determines the “visible” line position by merging any consecutive lines + /// that are spanned by text attachments. If an attachment overlaps beyond the + /// bounds of the original line, this method will extend the returned range to + /// cover the full span of those attachments (and recurse if further attachments + /// cross into newly included lines). + /// + /// For example, given the following: *(`[` == attachment start, `]` == attachment end)* + /// ``` + /// Line 1 + /// Line[ 2 + /// Line 3 + /// Line] 4 + /// ``` + /// If you start at the position for “Line 2”, the first and last attachments + /// overlap lines 2–4, so this method will extend the range to cover lines 2–4 + /// and return a position whose `range` spans the entire attachment. + /// + /// # Why recursion? + /// + /// When an attachment extends the visible range, it may pull in new lines that themselves overlap other + /// attachments. A simple one‐pass merge wouldn’t catch those secondary overlaps. By calling + /// determineVisiblePosition again on the newly extended range, we ensure that all cascading attachments—no matter + /// how many lines they span—are folded into a single, coherent TextLinePosition before returning. + /// + /// - Parameter originalPosition: The initial `TextLinePosition` to inspect. + /// Pass in the position you got from `lineStorage.getLine(atOffset:)` or similar. + /// - Returns: A tuple containing `position`: A `TextLinePosition` whose `range` and `index` have been + /// adjusted to include any attachment‐spanned lines.. `indexRange`: A `ClosedRange` listing all of + /// the line indices that are now covered by the returned position. + /// Returns `nil` if `originalPosition` is `nil`. + func determineVisiblePosition( + for originalPosition: TextLineStorage.TextLinePosition? + ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? { + guard let originalPosition else { return nil } + return determineVisiblePositionRecursively( + for: (originalPosition, originalPosition.index...originalPosition.index), + recursionDepth: 0 + ) + } + + /// Private implementation of ``TextLayoutManager/determineVisiblePosition(for:)``. + /// + /// Separated for readability. This method does not have an optional parameter, and keeps track of a recursion + /// depth. + private func determineVisiblePositionRecursively( + for originalPosition: (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange), + recursionDepth: Int + ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? { + // Arbitrary max recursion depth. Ensures we don't spiral into in an infinite recursion. + guard recursionDepth < 10 else { + logger.warning("Visible position recursed for over 10 levels, returning early.") + return originalPosition + } + + let attachments = attachments.getAttachmentsOverlapping(originalPosition.position.range) + guard let firstAttachment = attachments.first, let lastAttachment = attachments.last else { + // No change, either no attachments or attachment doesn't span multiple lines. + return originalPosition + } + + var minIndex = originalPosition.indexRange.lowerBound + var maxIndex = originalPosition.indexRange.upperBound + var newPosition = originalPosition.position + + if firstAttachment.range.location < originalPosition.position.range.location, + let extendedLinePosition = lineStorage.getLine(atOffset: firstAttachment.range.location) { + newPosition = TextLineStorage.TextLinePosition( + data: extendedLinePosition.data, + range: NSRange(start: extendedLinePosition.range.location, end: newPosition.range.max), + yPos: extendedLinePosition.yPos, + height: extendedLinePosition.height, + index: extendedLinePosition.index + ) + minIndex = min(minIndex, newPosition.index) + } + + if lastAttachment.range.max > originalPosition.position.range.max, + let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { + newPosition = TextLineStorage.TextLinePosition( + data: newPosition.data, + range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max), + yPos: newPosition.yPos, + height: newPosition.height, + index: newPosition.index // We want to keep the minimum index. + ) + maxIndex = max(maxIndex, extendedLinePosition.index) + } + + if firstAttachment.range.location == newPosition.range.location { + minIndex = max(minIndex, 0) + } + + if lastAttachment.range.max == newPosition.range.max { + maxIndex = min(maxIndex, lineStorage.count - 1) + } + + // Base case, we haven't updated anything + if minIndex...maxIndex == originalPosition.indexRange { + return (newPosition, minIndex...maxIndex) + } else { + // Recurse, to make sure we combine all necessary lines. + return determineVisiblePositionRecursively( + for: (newPosition, minIndex...maxIndex), + recursionDepth: recursionDepth + 1 + ) } } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift new file mode 100644 index 000000000..acf0ea0ae --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -0,0 +1,317 @@ +// +// TextLayoutManager+ensureLayout.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/7/25. +// + +import AppKit + +extension TextLayoutManager { + /// Contains all data required to perform layout on a text line. + private struct LineLayoutData { + let minY: CGFloat + let maxY: CGFloat + let maxWidth: CGFloat + } + + // MARK: - Layout Lines + + /// Lays out all visible lines + /// + /// ## Overview Of The Layout Routine + /// + /// The basic premise of this method is that it loops over all lines in the given rect (defaults to the visible + /// rect), checks if the line needs a layout calculation, and performs layout on the line if it does. + /// + /// The thing that makes this layout method so fast is the second point, checking if a line needs layout. To + /// determine if a line needs a layout pass, the layout manager can check three things: + /// - **1** Was the line laid out under the assumption of a different maximum layout width? + /// For instance, if a line was previously broken by the line wrapping setting, it won’t need to wrap once the + /// line wrapping is disabled. This will detect that, and cause the lines to be recalculated. + /// - **2** Was the line previously not visible? This is determined by keeping a set of visible line IDs. If the + /// line does not appear in that set, we can assume it was previously off screen and may need layout. + /// - **3** Was the line entirely laid out? We break up lines into line fragments. When we do layout, we determine + /// all line fragments but don't necessarily place them all in the view. This checks if all line fragments have + /// been placed in the view. If not, we need to place them. + /// + /// Once it has been determined that a line needs layout, we perform layout by recalculating it's line fragments, + /// removing all old line fragment views, and creating new ones for the line. + /// + /// ## Laziness + /// + /// At the end of the layout pass, we clean up any old lines by updating the set of visible line IDs and fragment + /// IDs. Any IDs that no longer appear in those sets are removed to save resources. This facilitates the text view's + /// ability to only render text that is visible and saves tons of resources (similar to the lazy loading of + /// collection or table views). + /// + /// The other important lazy attribute is the line iteration. Line iteration is done lazily. As we iterate + /// through lines and potentially update their heights, the next line is only queried for *after* the updates are + /// finished. + /// + /// ## Reentry + /// + /// An important thing to note is that this method cannot be reentered. If a layout pass has begun while a layout + /// pass is already ongoing, internal data structures will be broken. In debug builds, this is checked with a simple + /// boolean and assertion. + /// + /// To help ensure this property, all view modifications are performed within a `CATransaction`. This guarantees + /// that macOS calls `layout` on any related views only after we’ve finished inserting and removing line fragment + /// views. Otherwise, inserting a line fragment view could trigger a layout pass prematurely and cause this method + /// to re-enter. + /// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this + /// is not the way to do so. This should only be called when macOS performs layout. + @discardableResult + public func layoutLines(in rect: NSRect? = nil) -> Set { // swiftlint:disable:this function_body_length + guard let visibleRect = rect ?? delegate?.visibleRect, + !isInTransaction, + let textStorage else { + return [] + } + + // The macOS may call `layout` on the textView while we're laying out fragment views. This ensures the view + // tree modifications caused by this method are atomic, so macOS won't call `layout` while we're already doing + // that + CATransaction.begin() + layoutLock.lock() + + let minY = max(visibleRect.minY - verticalLayoutPadding, 0) + let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) + let originalHeight = lineStorage.height + var usedFragmentIDs = Set() + let forceLayout: Bool = needsLayout + var didLayoutChange = false + var newVisibleLines: Set = [] + var yContentAdjustment: CGFloat = 0 + var maxFoundLineWidth = maxLineWidth + +#if DEBUG + var laidOutLines: Set = [] +#endif + // Layout all lines, fetching lines lazily as they are laid out. + for linePosition in linesStartingAt(minY, until: maxY).lazy { + guard linePosition.yPos < maxY else { continue } + // Three ways to determine if a line needs to be re-calculated. + let linePositionNeedsLayout = linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) + let wasNotVisible = !visibleLineIds.contains(linePosition.data.id) + let lineNotEntirelyLaidOut = linePosition.height != linePosition.data.lineFragments.height + + defer { newVisibleLines.insert(linePosition.data.id) } + + func fullLineLayout() { + let (yAdjustment, wasLineHeightChanged) = layoutLine( + linePosition, + usedFragmentIDs: &usedFragmentIDs, + textStorage: textStorage, + yRange: minY.. 0 { + // Layout happened and this line needs to be moved but not necessarily re-added + let needsFullLayout = updateLineViewPositions(linePosition) + if needsFullLayout { + fullLineLayout() + continue + } + } + + // Make sure the used fragment views aren't dequeued. + usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id)) + } + } + + // Enqueue any lines not used in this layout pass. + viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs) + + // Update the visible lines with the new set. + visibleLineIds = newVisibleLines + + // The delegate methods below may call another layout pass, make sure we don't send it into a loop of forced + // layout. + needsLayout = false + + // Commit the view tree changes we just made. + layoutLock.unlock() + CATransaction.commit() + + if maxFoundLineWidth > maxLineWidth { + maxLineWidth = maxFoundLineWidth + } + + if yContentAdjustment != 0 { + delegate?.layoutManagerYAdjustment(yContentAdjustment) + } + + if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) + } + +#if DEBUG + return laidOutLines +#else + return [] +#endif + } + + // MARK: - Layout Single Line + + private func layoutLine( + _ linePosition: TextLineStorage.TextLinePosition, + usedFragmentIDs: inout Set, + textStorage: NSTextStorage, + yRange: Range, + maxFoundLineWidth: inout CGFloat + ) -> (CGFloat, wasLineHeightChanged: Bool) { + let lineSize = layoutLineViews( + linePosition, + textStorage: textStorage, + layoutData: LineLayoutData(minY: yRange.lowerBound, maxY: yRange.upperBound, maxWidth: maxLineLayoutWidth), + laidOutFragmentIDs: &usedFragmentIDs + ) + let wasLineHeightChanged = lineSize.height != linePosition.height + var yContentAdjustment: CGFloat = 0.0 + var maxFoundLineWidth = maxFoundLineWidth + + if wasLineHeightChanged { + lineStorage.update( + atOffset: linePosition.range.location, + delta: 0, + deltaHeight: lineSize.height - linePosition.height + ) + + if linePosition.yPos < yRange.lowerBound { + // Adjust the scroll position by the difference between the new height and old. + yContentAdjustment += lineSize.height - linePosition.height + } + } + if maxFoundLineWidth < lineSize.width { + maxFoundLineWidth = lineSize.width + } + + return (yContentAdjustment, wasLineHeightChanged) + } + + /// Lays out a single text line. + /// - Parameters: + /// - position: The line position from storage to use for layout. + /// - textStorage: The text storage object to use for text info. + /// - layoutData: The information required to perform layout for the given line. + /// - laidOutFragmentIDs: Updated by this method as line fragments are laid out. + /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line. + private func layoutLineViews( + _ position: TextLineStorage.TextLinePosition, + textStorage: NSTextStorage, + layoutData: LineLayoutData, + laidOutFragmentIDs: inout Set + ) -> CGSize { + let lineDisplayData = TextLine.DisplayData( + maxWidth: layoutData.maxWidth, + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimateLineHeight(), + breakStrategy: lineBreakStrategy + ) + + let line = position.data + if let renderDelegate { + renderDelegate.prepareForDisplay( + textLine: line, + displayData: lineDisplayData, + range: position.range, + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range), + attachments: attachments.getAttachmentsStartingIn(position.range) + ) + } else { + line.prepareForDisplay( + displayData: lineDisplayData, + range: position.range, + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range), + attachments: attachments.getAttachmentsStartingIn(position.range) + ) + } + + if position.range.isEmpty { + return CGSize(width: 0, height: estimateLineHeight()) + } + + var height: CGFloat = 0 + var width: CGFloat = 0 + let relativeMinY = max(layoutData.minY - position.yPos, 0) + let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) + +// for lineFragmentPosition in line.lineFragments.linesStartingAt( +// relativeMinY, +// until: relativeMaxY +// ) { + for lineFragmentPosition in line.lineFragments { + let lineFragment = lineFragmentPosition.data + lineFragment.documentRange = lineFragmentPosition.range.translate(location: position.range.location) + + layoutFragmentView( + inLine: position, + for: lineFragmentPosition, + at: position.yPos + lineFragmentPosition.yPos + ) + + width = max(width, lineFragment.width) + height += lineFragment.scaledHeight + laidOutFragmentIDs.insert(lineFragment.id) + } + + return CGSize(width: width, height: height) + } + + // MARK: - Layout Fragment + + /// Lays out a line fragment view for the given line fragment at the specified y value. + /// - Parameters: + /// - lineFragment: The line fragment position to lay out a view for. + /// - yPos: The y value at which the line should begin. + private func layoutFragmentView( + inLine line: TextLineStorage.TextLinePosition, + for lineFragment: TextLineStorage.TextLinePosition, + at yPos: CGFloat + ) { + let fragmentRange = lineFragment.range.translate(location: line.range.location) + let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) { + renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() + } + view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews + view.setLineFragment(lineFragment.data, fragmentRange: fragmentRange, renderer: lineFragmentRenderer) + view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) + layoutView?.addSubview(view, positioned: .below, relativeTo: nil) + view.needsDisplay = true + } + + private func updateLineViewPositions(_ position: TextLineStorage.TextLinePosition) -> Bool { + let line = position.data + for lineFragmentPosition in line.lineFragments { + guard let view = viewReuseQueue.getView(forKey: lineFragmentPosition.data.id) else { + return true + } + lineFragmentPosition.data.documentRange = lineFragmentPosition.range.translate( + location: position.range.location + ) + view.frame.origin = CGPoint(x: edgeInsets.left, y: position.yPos + lineFragmentPosition.yPos) + } + return false + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 34617613d..b73c17177 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -8,6 +8,8 @@ import AppKit extension TextLayoutManager { + // MARK: - Estimate + public func estimatedHeight() -> CGFloat { max(lineStorage.height, estimateLineHeight()) } @@ -16,6 +18,8 @@ extension TextLayoutManager { maxLineWidth + edgeInsets.horizontal } + // MARK: - Text Lines + /// Finds a text line for the given y position relative to the text view. /// /// Y values begin at the top of the view and extend down. Eg, a `0` y value would return the first line in @@ -25,7 +29,7 @@ extension TextLayoutManager { /// - Parameter posY: The y position to find a line for. /// - Returns: A text line position, if a line could be found at the given y position. public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? { - lineStorage.getLine(atPosition: posY) + determineVisiblePosition(for: lineStorage.getLine(atPosition: posY))?.position } /// Finds a text line for a given text offset. @@ -42,7 +46,7 @@ extension TextLayoutManager { if offset == lineStorage.length { return lineStorage.last } else { - return lineStorage.getLine(atOffset: offset) + return determineVisiblePosition(for: lineStorage.getLine(atOffset: offset))?.position } } @@ -52,7 +56,7 @@ extension TextLayoutManager { /// - Returns: The text line position if any, `nil` if the index is out of bounds. public func textLineForIndex(_ index: Int) -> TextLineStorage.TextLinePosition? { guard index >= 0 && index < lineStorage.count else { return nil } - return lineStorage.getLine(atIndex: index) + return determineVisiblePosition(for: lineStorage.getLine(atIndex: index))?.position } /// Calculates the text position at the given point in the view. @@ -65,42 +69,97 @@ extension TextLayoutManager { guard point.y <= estimatedHeight() else { // End position is a special case. return textStorage?.length } - guard let position = lineStorage.getLine(atPosition: point.y), - let fragmentPosition = position.data.typesetter.lineFragments.getLine( - atPosition: point.y - position.yPos + guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atPosition: point.y))?.position, + let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( + atPosition: point.y - linePosition.yPos ) else { return nil } - let fragment = fragmentPosition.data + return textOffsetAtPoint(point, fragmentPosition: fragmentPosition, linePosition: linePosition) + } + + func textOffsetAtPoint( + _ point: CGPoint, + fragmentPosition: TextLineStorage.TextLinePosition, + linePosition: TextLineStorage.TextLinePosition + ) -> Int? { + let fragment = fragmentPosition.data if fragment.width == 0 { - return position.range.location + fragmentPosition.range.location - } else if fragment.width < point.x - edgeInsets.left { - let fragmentRange = CTLineGetStringRange(fragment.ctLine) - let globalFragmentRange = NSRange( - location: position.range.location + fragmentRange.location, - length: fragmentRange.length - ) - let endPosition = position.range.location + fragmentRange.location + fragmentRange.length - - // If the endPosition is at the end of the line, and the line ends with a line ending character - // return the index before the eol. - if endPosition == position.range.max, - let lineEnding = LineEnding(line: textStorage?.substring(from: globalFragmentRange) ?? "") { - return endPosition - lineEnding.length - } else { - return endPosition - } + return linePosition.range.location + fragmentPosition.range.location + } else if fragment.width <= point.x - edgeInsets.left { + return findOffsetAfterEndOf(fragmentPosition: fragmentPosition, in: linePosition) } else { - // Somewhere in the fragment + return findOffsetAtPoint(inFragment: fragment, xPos: point.x, inLine: linePosition) + } + } + + /// Finds a document offset after a line fragment. Returns a cursor position. + /// + /// If the fragment ends the line, return the position before the potential line break. This visually positions the + /// cursor at the end of the line, but before the break character. If deleted, it edits the visually selected line. + /// + /// If not at the line end, do the same with the fragment and respect any composed character sequences at + /// the line break. + /// + /// Return the line end position otherwise. + /// + /// - Parameters: + /// - fragmentPosition: The fragment position being queried. + /// - linePosition: The line position that contains the `fragment`. + /// - Returns: The position visually at the end of the line fragment. + private func findOffsetAfterEndOf( + fragmentPosition: TextLineStorage.TextLinePosition, + in linePosition: TextLineStorage.TextLinePosition + ) -> Int? { + let fragmentRange = fragmentPosition.range.translate(location: linePosition.range.location) + let endPosition = fragmentRange.max + + // If the endPosition is at the end of the line, and the line ends with a line ending character + // return the index before the eol. + if fragmentPosition.index == linePosition.data.lineFragments.count - 1, + let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") { + return endPosition - lineEnding.length + } else if fragmentPosition.index != linePosition.data.lineFragments.count - 1 { + // If this isn't the last fragment, we want to place the cursor at the offset right before the break + // index, to appear on the end of *this* fragment. + let string = (textStorage?.string as? NSString) + return string?.rangeOfComposedCharacterSequence(at: endPosition - 1).location + } else { + // Otherwise, return the end of the fragment (and the end of the line). + return endPosition + } + } + + /// Finds a document offset for a point that lies in a line fragment. + /// - Parameters: + /// - fragment: The fragment the point lies in. + /// - xPos: The point being queried, relative to the text view. + /// - linePosition: The position that contains the `fragment`. + /// - Returns: The offset (relative to the document) that's closest to the given point, or `nil` if it could not be + /// found. + func findOffsetAtPoint( + inFragment fragment: LineFragment, + xPos: CGFloat, + inLine linePosition: TextLineStorage.TextLinePosition + ) -> Int? { + guard let (content, contentPosition) = fragment.findContent(atX: xPos - edgeInsets.left) else { + return nil + } + switch content.data { + case .text(let ctLine): let fragmentIndex = CTLineGetStringIndexForPosition( - fragment.ctLine, - CGPoint(x: point.x - edgeInsets.left, y: fragment.height/2) + ctLine, + CGPoint(x: xPos - edgeInsets.left - contentPosition.xPos, y: fragment.height/2) ) - return position.range.location + fragmentIndex + return fragmentIndex + contentPosition.offset + linePosition.range.location + case .attachment: + return contentPosition.offset + linePosition.range.location } } + // MARK: - Rect For Offset + /// Find a position for the character at a given offset. /// Returns the rect of the character at the given offset. /// The rect may represent more than one unicode unit, for instance if the offset is at the beginning of an @@ -108,41 +167,35 @@ extension TextLayoutManager { /// - Parameter offset: The offset to create the rect for. /// - Returns: The found rect for the given offset. public func rectForOffset(_ offset: Int) -> CGRect? { - guard offset != lineStorage.length else { + guard offset < lineStorage.length else { return rectForEndOffset() } - guard let linePosition = lineStorage.getLine(atOffset: offset) else { + guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atOffset: offset))?.position else { return nil } - if linePosition.data.lineFragments.isEmpty { - let newHeight = ensureLayoutFor(position: linePosition) - if linePosition.height != newHeight { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - } - guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( atOffset: offset - linePosition.range.location ) else { - return nil + return CGRect(x: edgeInsets.left, y: linePosition.yPos, width: 0, height: linePosition.height) } // Get the *real* length of the character at the offset. If this is a surrogate pair it'll return the correct // length of the character at the offset. - let realRange = textStorage?.length == 0 - ? NSRange(location: offset, length: 0) - : (textStorage?.string as? NSString)?.rangeOfComposedCharacterSequence(at: offset) - ?? NSRange(location: offset, length: 0) - - let minXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRange.location - linePosition.range.location, // CTLines have the same relative range as the line - nil + let realRange = if textStorage?.length == 0 { + NSRange(location: offset, length: 0) + } else if let string = textStorage?.string as? NSString { + string.rangeOfComposedCharacterSequence(at: offset) + } else { + NSRange(location: offset, length: 0) + } + + let minXPos = characterXPosition( + in: fragmentPosition.data, + for: realRange.location - linePosition.range.location - fragmentPosition.range.location ) - let maxXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRange.max - linePosition.range.location, - nil + let maxXPos = characterXPosition( + in: fragmentPosition.data, + for: realRange.max - linePosition.range.location - fragmentPosition.range.location ) return CGRect( @@ -153,12 +206,57 @@ extension TextLayoutManager { ) } - // swiftlint:disable function_body_length + /// Calculates all text bounding rects that intersect with a given range. + /// - Parameters: + /// - range: The range to calculate bounding rects for. + /// - line: The line to calculate rects for. + /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. + public func rectsFor(range: NSRange) -> [CGRect] { + return linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } + } + + /// Calculates all text bounding rects that intersect with a given range, with a given line position. + /// - Parameters: + /// - range: The range to calculate bounding rects for. + /// - line: The line to calculate rects for. + /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. + private func rectsFor(range: NSRange, in line: borrowing TextLineStorage.TextLinePosition) -> [CGRect] { + guard let textStorage = (textStorage?.string as? NSString) else { return [] } + + // Don't make rects in between characters + let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: range.lowerBound) + let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: range.upperBound - 1) + + // Fragments are relative to the line + let relativeRange = NSRange( + start: realRangeStart.lowerBound - line.range.location, + end: realRangeEnd.upperBound - line.range.location + ) + + var rects: [CGRect] = [] + for fragmentPosition in line.data.lineFragments.linesInRange(relativeRange) { + guard let intersectingRange = fragmentPosition.range.intersection(relativeRange) else { continue } + let fragmentRect = characterRect(in: fragmentPosition.data, for: intersectingRange) + guard fragmentRect.width > 0 else { continue } + rects.append( + CGRect( + x: fragmentRect.minX + edgeInsets.left, + y: fragmentPosition.yPos + line.yPos, + width: fragmentRect.width, + height: fragmentRect.height + ) + ) + } + return rects + } + /// Creates a smooth bezier path for the specified range. /// If the range exceeds the available text, it uses the maximum available range. - /// - Parameter range: The range of text offsets to generate the path for. + /// - Parameters: + /// - range: The range of text offsets to generate the path for. + /// - cornerRadius: The radius of the edges when rounding. Defaults to four. /// - Returns: An `NSBezierPath` representing the visual shape for the text range, or `nil` if the range is invalid. - public func roundedPathForRange(_ range: NSRange) -> NSBezierPath? { + public func roundedPathForRange(_ range: NSRange, cornerRadius: CGFloat = 4) -> NSBezierPath? { // Ensure the range is within the bounds of the text storage let validRange = NSRange( location: range.lowerBound, @@ -170,76 +268,34 @@ extension TextLayoutManager { var rightSidePoints: [CGPoint] = [] // Points for Bottom-right → Top-right var leftSidePoints: [CGPoint] = [] // Points for Bottom-left → Top-left - var currentOffset = validRange.lowerBound - - // Process each line fragment within the range - while currentOffset < validRange.upperBound { - guard let linePosition = lineStorage.getLine(atOffset: currentOffset) else { return nil } - - if linePosition.data.lineFragments.isEmpty { - let newHeight = ensureLayoutFor(position: linePosition) - if linePosition.height != newHeight { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - } - - guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( - atOffset: currentOffset - linePosition.range.location - ) else { break } - - // Calculate the X positions for the range's boundaries within the fragment - let realRangeStart = (textStorage?.string as? NSString)? - .rangeOfComposedCharacterSequence(at: validRange.lowerBound) - ?? NSRange(location: validRange.lowerBound, length: 0) - - let realRangeEnd = (textStorage?.string as? NSString)? - .rangeOfComposedCharacterSequence(at: validRange.upperBound - 1) - ?? NSRange(location: validRange.upperBound - 1, length: 0) - - let minXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRangeStart.location - linePosition.range.location, - nil - ) + edgeInsets.left - - let maxXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRangeEnd.upperBound - linePosition.range.location, - nil - ) + edgeInsets.left - - // Ensure the fragment has a valid width - guard maxXPos > minXPos else { break } - - // Add the Y positions for the fragment - let topY = linePosition.yPos + fragmentPosition.yPos + fragmentPosition.data.scaledHeight - let bottomY = linePosition.yPos + fragmentPosition.yPos - - // Append points in the correct order - rightSidePoints.append(contentsOf: [ - CGPoint(x: maxXPos, y: bottomY), // Bottom-right - CGPoint(x: maxXPos, y: topY) // Top-right - ]) - leftSidePoints.insert(contentsOf: [ - CGPoint(x: minXPos, y: topY), // Top-left - CGPoint(x: minXPos, y: bottomY) // Bottom-left - ], at: 0) - - // Move to the next fragment - currentOffset = min(validRange.upperBound, linePosition.range.upperBound) + for fragmentRect in rectsFor(range: range) { + rightSidePoints.append( + contentsOf: [ + CGPoint(x: fragmentRect.maxX, y: fragmentRect.minY), // Bottom-right + CGPoint(x: fragmentRect.maxX, y: fragmentRect.maxY) // Top-right + ] + ) + leftSidePoints.insert( + contentsOf: [ + CGPoint(x: fragmentRect.minX, y: fragmentRect.maxY), // Top-left + CGPoint(x: fragmentRect.minX, y: fragmentRect.minY) // Bottom-left + ], + at: 0 + ) } // Combine the points in clockwise order let points = leftSidePoints + rightSidePoints + guard points.allSatisfy({ $0.x.isFinite && $0.y.isFinite }) else { return nil } + // Close the path if let firstPoint = points.first { - return NSBezierPath.smoothPath(points + [firstPoint], radius: 2) + return NSBezierPath.smoothPath(points + [firstPoint], radius: cornerRadius) } return nil } - // swiftlint:enable function_body_length /// Finds a suitable cursor rect for the end position. /// - Returns: A CGRect if it could be created. @@ -263,56 +319,33 @@ extension TextLayoutManager { return nil } - /// Forces layout calculation for all lines up to and including the given offset. - /// - Parameter offset: The offset to ensure layout until. - public func ensureLayoutUntil(_ offset: Int) { - guard let linePosition = lineStorage.getLine(atOffset: offset), - let visibleRect = delegate?.visibleRect, - visibleRect.maxY < linePosition.yPos + linePosition.height, - let startingLinePosition = lineStorage.getLine(atPosition: visibleRect.minY) - else { - return - } - let originalHeight = lineStorage.height - - for linePosition in lineStorage.linesInRange( - NSRange(start: startingLinePosition.range.location, end: linePosition.range.max) - ) { - let height = ensureLayoutFor(position: linePosition) - if height != linePosition.height { - lineStorage.update( - atIndex: linePosition.range.location, - delta: 0, - deltaHeight: height - linePosition.height - ) - } - } + // MARK: - Line Fragment Rects - if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } + /// Finds the x position of the offset in the string the fragment represents. + /// - Parameters: + /// - lineFragment: The line fragment to calculate for. + /// - offset: The offset, relative to the start of the *line*. + /// - Returns: The x position of the character in the drawn line, from the left. + public func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { + renderDelegate?.characterXPosition(in: lineFragment, for: offset) ?? lineFragment._xPos(for: offset) } - /// Forces layout calculation for all lines up to and including the given offset. - /// - Parameter offset: The offset to ensure layout until. - private func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { - guard let textStorage else { return 0 } - let displayData = TextLine.DisplayData( - maxWidth: maxLineLayoutWidth, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight() - ) - position.data.prepareForDisplay( - displayData: displayData, - range: position.range, - stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy - ) - var height: CGFloat = 0 - for fragmentPosition in position.data.lineFragments { - height += fragmentPosition.data.scaledHeight + public func characterRect(in lineFragment: LineFragment, for range: NSRange) -> CGRect { + let minXPos = characterXPosition(in: lineFragment, for: range.lowerBound) + let maxXPos = characterXPosition(in: lineFragment, for: range.upperBound) + return CGRect( + x: minXPos, + y: 0, + width: maxXPos - minXPos, + height: lineFragment.scaledHeight + ).pixelAligned + } + + func contentRun(at offset: Int) -> LineFragment.FragmentContent? { + guard let textLine = textLineForOffset(offset), + let fragment = textLine.data.lineFragments.getLine(atOffset: offset - textLine.range.location) else { + return nil } - return height + return fragment.data.findContent(at: offset - textLine.range.location - fragment.range.location)?.content } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift deleted file mode 100644 index c160bfd57..000000000 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// TextLayoutManager+Transaction.swift -// CodeEditTextView -// -// Created by Khan Winter on 2/24/24. -// - -import Foundation - -extension TextLayoutManager { - /// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called. - /// Useful for grouping attribute modifications into one layout pass rather than laying out every update. - /// - /// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction - /// group is ended. - /// - /// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout - /// manager will never lay out text. If there is a end call without matching a start call an assertionFailure - /// will occur. - public func beginTransaction() { - transactionCounter += 1 - } - - /// Ends a transaction. When called, the layout manager will layout any necessary lines. - public func endTransaction(forceLayout: Bool = false) { - transactionCounter -= 1 - if transactionCounter == 0 { - if forceLayout { - setNeedsLayout() - } - layoutLines() - } else if transactionCounter < 0 { - assertionFailure( - "TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call" - ) - } - } -} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 1173b81dc..0a8b57ffd 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -8,16 +8,6 @@ import Foundation import AppKit -public protocol TextLayoutManagerDelegate: AnyObject { - func layoutManagerHeightDidUpdate(newHeight: CGFloat) - func layoutManagerMaxWidthDidChange(newWidth: CGFloat) - func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] - func textViewportSize() -> CGSize - func layoutManagerYAdjustment(_ yAdjustment: CGFloat) - - var visibleRect: NSRect { get } -} - /// The text layout manager manages laying out lines in a code document. public class TextLayoutManager: NSObject { // MARK: - Public Properties @@ -65,12 +55,32 @@ public class TextLayoutManager: NSObject { } } + public weak var renderDelegate: TextLayoutManagerRenderDelegate? { + didSet { + // Rebuild using potentially overridden behavior. + _estimateLineHeight = nil + lineStorage.removeAll() + prepareTextLines() + } + } + + public let attachments: TextAttachmentManager = TextAttachmentManager() + + public weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? { + didSet { + lineFragmentRenderer.invisibleCharacterDelegate = invisibleCharacterDelegate + layoutView?.needsDisplay = true + } + } + // MARK: - Internal weak var textStorage: NSTextStorage? - var lineStorage: TextLineStorage = TextLineStorage() + public var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() - private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() + let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() + let lineFragmentRenderer: LineFragmentRenderer + package var visibleLineIds: Set = [] /// Used to force a complete re-layout using `setNeedsLayout` package var needsLayout: Bool = false @@ -79,13 +89,10 @@ public class TextLayoutManager: NSObject { public var isInTransaction: Bool { transactionCounter > 0 } - #if DEBUG + /// Guard variable for an assertion check in debug builds. /// Ensures that layout calls are not overlapping, potentially causing layout issues. - /// This is used over a lock, as locks in performant code such as this would be detrimental to performance. - /// Also only included in debug builds. DO NOT USE for checking if layout is active or not. That is an anti-pattern. - private var isInLayout: Bool = false - #endif + var layoutLock: NSLock = NSLock() weak var layoutView: NSView? @@ -100,22 +107,15 @@ public class TextLayoutManager: NSObject { /// The maximum width available to lay out lines in, used to determine how much space is available for laying out /// lines. Evals to `.greatestFiniteMagnitude` when ``wrapLines`` is `false`. - var maxLineLayoutWidth: CGFloat { + public var maxLineLayoutWidth: CGFloat { wrapLines ? wrapLinesWidth : .greatestFiniteMagnitude } /// The width of the space available to draw text fragments when wrapping lines. - var wrapLinesWidth: CGFloat { + public var wrapLinesWidth: CGFloat { (delegate?.textViewportSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal } - /// Contains all data required to perform layout on a text line. - private struct LineLayoutData { - let minY: CGFloat - let maxY: CGFloat - let maxWidth: CGFloat - } - // MARK: - Init /// Initialize a text layout manager and prepare it for use. @@ -125,20 +125,29 @@ public class TextLayoutManager: NSObject { /// - wrapLines: Set to true to wrap lines to the visible editor width. /// - textView: The view to layout text fragments in. /// - delegate: A delegate for the layout manager. - init( + public init( textStorage: NSTextStorage, lineHeightMultiplier: CGFloat, wrapLines: Bool, textView: NSView, - delegate: TextLayoutManagerDelegate? + delegate: TextLayoutManagerDelegate?, + renderDelegate: TextLayoutManagerRenderDelegate? = nil, + invisibleCharacterDelegate: InvisibleCharactersDelegate? = nil ) { self.textStorage = textStorage self.lineHeightMultiplier = lineHeightMultiplier self.wrapLines = wrapLines self.layoutView = textView self.delegate = delegate + self.renderDelegate = renderDelegate + self.lineFragmentRenderer = LineFragmentRenderer( + textStorage: textStorage, + invisibleCharacterDelegate: invisibleCharacterDelegate + ) + self.invisibleCharacterDelegate = invisibleCharacterDelegate super.init() prepareTextLines() + attachments.layoutManager = self } /// Prepares the layout manager for use. @@ -159,8 +168,13 @@ public class TextLayoutManager: NSObject { let end = mach_absolute_time() let elapsed = end - start let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom) - let msec = TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC) - logger.info("TextLayoutManager built in: \(msec, privacy: .public)ms") + let sec = TimeInterval(nanos) / TimeInterval(NSEC_PER_SEC) + // This used to be logged every time. However we're now confident enough in the performance of this method + // that it's not useful to log it anymore unless it's an odd number. Taking ~500ms for a >500k loc file + // is normal. More than 1s for any document is not normal. + if sec >= 1 { + logger.warning("TextLayoutManager built in: \(sec, privacy: .public)s") + } #endif } @@ -172,6 +186,7 @@ public class TextLayoutManager: NSObject { viewReuseQueue.usedViews.removeAll() maxLineWidth = 0 markedTextManager.removeAll() + lineFragmentRenderer.textStorage = textStorage prepareTextLines() setNeedsLayout() } @@ -182,6 +197,9 @@ public class TextLayoutManager: NSObject { public func estimateLineHeight() -> CGFloat { if let _estimateLineHeight { return _estimateLineHeight + } else if let estimate = renderDelegate?.estimatedLineHeight() { + _estimateLineHeight = estimate + return estimate } else { let string = NSAttributedString(string: "0", attributes: delegate?.layoutManagerTypingAttributes() ?? [:]) let typesetter = CTTypesetterCreateWithAttributedString(string) @@ -190,8 +208,9 @@ public class TextLayoutManager: NSObject { var descent: CGFloat = 0 var leading: CGFloat = 0 CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading) - _estimateLineHeight = (ascent + descent + leading) * lineHeightMultiplier - return _estimateLineHeight! + let height = (ascent + descent + leading) * lineHeightMultiplier + _estimateLineHeight = height + return height } } @@ -199,169 +218,6 @@ public class TextLayoutManager: NSObject { /// ``TextLayoutManager/estimateLineHeight()`` is called. private var _estimateLineHeight: CGFloat? - // MARK: - Layout - - /// Asserts that the caller is not in an active layout pass. - /// See docs on ``isInLayout`` for more details. - private func assertNotInLayout() { - #if DEBUG // This is redundant, but it keeps the flag debug-only too which helps prevent misuse. - assert(!isInLayout, "layoutLines called while already in a layout pass. This is a programmer error.") - #endif - } - - /// Lays out all visible lines - func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length - assertNotInLayout() - guard layoutView?.superview != nil, - let visibleRect = rect ?? delegate?.visibleRect, - !isInTransaction, - let textStorage else { - return - } - #if DEBUG - isInLayout = true - #endif - let minY = max(visibleRect.minY - verticalLayoutPadding, 0) - let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) - let originalHeight = lineStorage.height - var usedFragmentIDs = Set() - var forceLayout: Bool = needsLayout - var newVisibleLines: Set = [] - var yContentAdjustment: CGFloat = 0 - var maxFoundLineWidth = maxLineWidth - - // Layout all lines, fetching lines lazily as they are laid out. - for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy { - guard linePosition.yPos < maxY else { break } - if forceLayout - || linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) - || !visibleLineIds.contains(linePosition.data.id) { - let lineSize = layoutLine( - linePosition, - textStorage: textStorage, - layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth), - laidOutFragmentIDs: &usedFragmentIDs - ) - if lineSize.height != linePosition.height { - lineStorage.update( - atIndex: linePosition.range.location, - delta: 0, - deltaHeight: lineSize.height - linePosition.height - ) - // If we've updated a line's height, force re-layout for the rest of the pass. - forceLayout = true - - if linePosition.yPos < minY { - // Adjust the scroll position by the difference between the new height and old. - yContentAdjustment += lineSize.height - linePosition.height - } - } - if maxFoundLineWidth < lineSize.width { - maxFoundLineWidth = lineSize.width - } - } else { - // Make sure the used fragment views aren't dequeued. - usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id)) - } - newVisibleLines.insert(linePosition.data.id) - } - - // Enqueue any lines not used in this layout pass. - viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs) - - // Update the visible lines with the new set. - visibleLineIds = newVisibleLines - - #if DEBUG - isInLayout = false - #endif - - // These are fine to update outside of `isInLayout` as our internal data structures are finalized at this point - // so laying out again won't break our line storage or visible line. - - if maxFoundLineWidth > maxLineWidth { - maxLineWidth = maxFoundLineWidth - } - - if yContentAdjustment != 0 { - delegate?.layoutManagerYAdjustment(yContentAdjustment) - } - - if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - - needsLayout = false - } - - /// Lays out a single text line. - /// - Parameters: - /// - position: The line position from storage to use for layout. - /// - textStorage: The text storage object to use for text info. - /// - layoutData: The information required to perform layout for the given line. - /// - laidOutFragmentIDs: Updated by this method as line fragments are laid out. - /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line. - private func layoutLine( - _ position: TextLineStorage.TextLinePosition, - textStorage: NSTextStorage, - layoutData: LineLayoutData, - laidOutFragmentIDs: inout Set - ) -> CGSize { - let lineDisplayData = TextLine.DisplayData( - maxWidth: layoutData.maxWidth, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight() - ) - - let line = position.data - line.prepareForDisplay( - displayData: lineDisplayData, - range: position.range, - stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy - ) - - if position.range.isEmpty { - return CGSize(width: 0, height: estimateLineHeight()) - } - - var height: CGFloat = 0 - var width: CGFloat = 0 - let relativeMinY = max(layoutData.minY - position.yPos, 0) - let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) - - for lineFragmentPosition in line.lineFragments.linesStartingAt( - relativeMinY, - until: relativeMaxY - ) { - let lineFragment = lineFragmentPosition.data - - layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos) - - width = max(width, lineFragment.width) - height += lineFragment.scaledHeight - laidOutFragmentIDs.insert(lineFragment.id) - } - - return CGSize(width: width, height: height) - } - - /// Lays out a line fragment view for the given line fragment at the specified y value. - /// - Parameters: - /// - lineFragment: The line fragment position to lay out a view for. - /// - yPos: The y value at which the line should begin. - private func layoutFragmentView( - for lineFragment: TextLineStorage.TextLinePosition, - at yPos: CGFloat - ) { - let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) - view.setLineFragment(lineFragment.data) - view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) - layoutView?.addSubview(view) - view.needsDisplay = true - } - deinit { lineStorage.removeAll() layoutView = nil diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerDelegate.swift new file mode 100644 index 000000000..b6850b3cc --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerDelegate.swift @@ -0,0 +1,18 @@ +// +// TextLayoutManagerDelegate.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit + +public protocol TextLayoutManagerDelegate: AnyObject { + func layoutManagerHeightDidUpdate(newHeight: CGFloat) + func layoutManagerMaxWidthDidChange(newWidth: CGFloat) + func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] + func textViewportSize() -> CGSize + func layoutManagerYAdjustment(_ yAdjustment: CGFloat) + + var visibleRect: NSRect { get } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift new file mode 100644 index 000000000..34e930b75 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -0,0 +1,60 @@ +// +// TextLayoutManagerRenderDelegate.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit + +/// Provide an instance of this class to the ``TextLayoutManager`` to override how the layout manager performs layout +/// and display for text lines and fragments. +/// +/// All methods on this protocol are optional, and default to the default behavior. +public protocol TextLayoutManagerRenderDelegate: AnyObject { + func prepareForDisplay( // swiftlint:disable:this function_parameter_count + textLine: TextLine, + displayData: TextLine.DisplayData, + range: NSRange, + stringRef: NSTextStorage, + markedRanges: MarkedRanges?, + attachments: [AnyTextAttachment] + ) + + func estimatedLineHeight() -> CGFloat? + + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView + + func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat +} + +public extension TextLayoutManagerRenderDelegate { + func prepareForDisplay( // swiftlint:disable:this function_parameter_count + textLine: TextLine, + displayData: TextLine.DisplayData, + range: NSRange, + stringRef: NSTextStorage, + markedRanges: MarkedRanges?, + attachments: [AnyTextAttachment] + ) { + textLine.prepareForDisplay( + displayData: displayData, + range: range, + stringRef: stringRef, + markedRanges: markedRanges, + attachments: attachments + ) + } + + func estimatedLineHeight() -> CGFloat? { + nil + } + + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { + LineFragmentView() + } + + func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { + lineFragment._xPos(for: offset) + } +} diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index ebe6db74b..646bf76ba 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -6,16 +6,53 @@ // import AppKit +import CodeEditTextViewObjC /// A ``LineFragment`` represents a subrange of characters in a line. Every text line contains at least one line /// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment. public final class LineFragment: Identifiable, Equatable { + public struct FragmentContent: Equatable { + public enum Content: Equatable { + case text(line: CTLine) + case attachment(attachment: AnyTextAttachment) + } + + public let data: Content + public let width: CGFloat + + public var length: Int { + switch data { + case .text(let line): + CTLineGetStringRange(line).length + case .attachment(let attachment): + attachment.range.length + } + } + +#if DEBUG + var isText: Bool { + switch data { + case .text: + true + case .attachment: + false + } + } +#endif + } + + public struct ContentPosition { + let xPos: CGFloat + let offset: Int + } + public let id = UUID() - private(set) public var ctLine: CTLine - public let width: CGFloat - public let height: CGFloat - public let descent: CGFloat - public let scaledHeight: CGFloat + public var documentRange: NSRange = .notFound + public var contents: [FragmentContent] + public var width: CGFloat + public var height: CGFloat + public var descent: CGFloat + public var scaledHeight: CGFloat /// The difference between the real text height and the scaled height public var heightDifference: CGFloat { @@ -23,13 +60,13 @@ public final class LineFragment: Identifiable, Equatable { } init( - ctLine: CTLine, + contents: [FragmentContent], width: CGFloat, height: CGFloat, descent: CGFloat, lineHeightMultiplier: CGFloat ) { - self.ctLine = ctLine + self.contents = contents self.width = width self.height = height self.descent = descent @@ -39,4 +76,61 @@ public final class LineFragment: Identifiable, Equatable { public static func == (lhs: LineFragment, rhs: LineFragment) -> Bool { lhs.id == rhs.id } + + /// Finds the x position of the offset in the string the fragment represents. + /// + /// Underscored, because although this needs to be accessible outside this class, the relevant layout manager method + /// should be used. + /// + /// - Parameter offset: The offset, relative to the start of the *line*. + /// - Returns: The x position of the character in the drawn line, from the left. + func _xPos(for offset: Int) -> CGFloat { + guard let (content, position) = findContent(at: offset) else { + return width + } + switch content.data { + case .text(let ctLine): + return CTLineGetOffsetForStringIndex( + ctLine, + CTLineGetStringRange(ctLine).location + offset - position.offset, + nil + ) + position.xPos + case .attachment: + return position.xPos + } + } + + package func findContent(at location: Int) -> (content: FragmentContent, position: ContentPosition)? { + var position = ContentPosition(xPos: 0, offset: 0) + + for content in contents { + let length = content.length + let width = content.width + + if (position.offset..<(position.offset + length)).contains(location) { + return (content, position) + } + + position = ContentPosition(xPos: position.xPos + width, offset: position.offset + length) + } + + return nil + } + + package func findContent(atX xPos: CGFloat) -> (content: FragmentContent, position: ContentPosition)? { + var position = ContentPosition(xPos: 0, offset: 0) + + for content in contents { + let length = content.length + let width = content.width + + if (position.xPos..<(position.xPos + width)).contains(xPos) { + return (content, position) + } + + position = ContentPosition(xPos: position.xPos + width, offset: position.offset + length) + } + + return nil + } } diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift new file mode 100644 index 000000000..6330d0ee0 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift @@ -0,0 +1,290 @@ +// +// LineFragmentRenderer.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/10/25. +// + +import AppKit +import CodeEditTextViewObjC + +/// Manages drawing line fragments into a drawing context. +public final class LineFragmentRenderer { + private struct CacheKey: Hashable { + let string: String + let font: NSFont + let color: NSColor + } + + private struct InvisibleDrawingContext { + let lineFragment: LineFragment + let ctLine: CTLine + let contentOffset: Int + let position: CGPoint + let context: CGContext + } + + weak var textStorage: NSTextStorage? + weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? + private var attributedStringCache: [CacheKey: CTLine] = [:] + + /// Create a fragment renderer. + /// - Parameters: + /// - textStorage: The text storage backing the fragments being drawn. + /// - invisibleCharacterDelegate: A delegate object to interrogate for invisible character drawing. + public init(textStorage: NSTextStorage?, invisibleCharacterDelegate: InvisibleCharactersDelegate?) { + self.textStorage = textStorage + self.invisibleCharacterDelegate = invisibleCharacterDelegate + } + + /// Draw the given line fragment into a drawing context, using the invisible character configuration determined + /// from the ``invisibleCharacterDelegate``, and line fragment information from the passed ``LineFragment`` object. + /// - Parameters: + /// - lineFragment: The line fragment to drawn + /// - context: The drawing context to draw into. + /// - yPos: In the drawing context, what `y` position to start drawing at. + public func draw(lineFragment: LineFragment, in context: CGContext, yPos: CGFloat) { + if invisibleCharacterDelegate?.invisibleStyleShouldClearCache() == true { + attributedStringCache.removeAll(keepingCapacity: true) + } + + context.saveGState() + // Removes jagged edges + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + + // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than + // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness + // in low-contrast settings. + context.setAllowsFontSubpixelPositioning(true) + context.setShouldSubpixelPositionFonts(true) + + // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher + // quality bitmaps and performance. + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + ContextSetHiddenSmoothingStyle(context, 16) + + context.textMatrix = .init(scaleX: 1, y: -1) + + var currentPosition: CGFloat = 0.0 + var currentLocation = 0 + for content in lineFragment.contents { + context.saveGState() + switch content.data { + case .text(let ctLine): + context.textPosition = CGPoint( + x: currentPosition, + y: yPos + lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) + ).pixelAligned + CTLineDraw(ctLine, context) + + drawInvisibles( + lineFragment: lineFragment, + for: ctLine, + contentOffset: currentLocation, + position: CGPoint(x: currentPosition, y: yPos), + in: context + ) + case .attachment(let attachment): + attachment.attachment.draw( + in: context, + rect: NSRect( + x: currentPosition, + y: yPos + (lineFragment.heightDifference/2), + width: attachment.width, + height: lineFragment.height + ) + ) + } + context.restoreGState() + currentPosition += content.width + currentLocation += content.length + } + context.restoreGState() + } + + private func drawInvisibles( + lineFragment: LineFragment, + for ctLine: CTLine, + contentOffset: Int, + position: CGPoint, + in context: CGContext + ) { + guard let textStorage, let invisibleCharacterDelegate else { return } + + let drawingContext = InvisibleDrawingContext( + lineFragment: lineFragment, + ctLine: ctLine, + contentOffset: contentOffset, + position: position, + context: context + ) + + let range = createTextRange(for: drawingContext).clamped(to: (textStorage.string as NSString).length) + let string = (textStorage.string as NSString).substring(with: range) + + processInvisibleCharacters( + in: string, + range: range, + delegate: invisibleCharacterDelegate, + drawingContext: drawingContext + ) + } + + private func createTextRange(for drawingContext: InvisibleDrawingContext) -> NSRange { + return NSRange( + start: drawingContext.lineFragment.documentRange.location + drawingContext.contentOffset, + end: drawingContext.lineFragment.documentRange.max + ) + } + + private func processInvisibleCharacters( + in string: String, + range: NSRange, + delegate: InvisibleCharactersDelegate, + drawingContext: InvisibleDrawingContext + ) { + drawingContext.context.saveGState() + defer { drawingContext.context.restoreGState() } + + lazy var offset = CTLineGetStringRange(drawingContext.ctLine).location + + for (idx, character) in string.utf16.enumerated() + where delegate.triggerCharacters.contains(character) { + processInvisibleCharacter( + character: character, + at: idx, + in: range, + offset: offset, + delegate: delegate, + drawingContext: drawingContext + ) + } + } + + // Disabling the next lint warning because I *cannot* figure out how to split this up further. + + private func processInvisibleCharacter( // swiftlint:disable:this function_parameter_count + character: UInt16, + at index: Int, + in range: NSRange, + offset: Int, + delegate: InvisibleCharactersDelegate, + drawingContext: InvisibleDrawingContext + ) { + guard let style = delegate.invisibleStyle( + for: character, + at: NSRange(start: range.location + index, end: range.max), + lineRange: drawingContext.lineFragment.documentRange + ) else { + return + } + + let xOffset = CTLineGetOffsetForStringIndex(drawingContext.ctLine, offset + index, nil) + + switch style { + case let .replace(replacementCharacter, color, font): + drawReplacementCharacter( + replacementCharacter, + color: color, + font: font, + at: calculateReplacementPosition( + basePosition: drawingContext.position, + xOffset: xOffset, + lineFragment: drawingContext.lineFragment + ), + in: drawingContext.context + ) + case let .emphasize(color): + let emphasizeRect = calculateEmphasisRect( + basePosition: drawingContext.position, + xOffset: xOffset, + characterIndex: index, + offset: offset, + drawingContext: drawingContext + ) + + drawEmphasis( + color: color, + forRect: emphasizeRect, + in: drawingContext.context + ) + } + } + + private func calculateReplacementPosition( + basePosition: CGPoint, + xOffset: CGFloat, + lineFragment: LineFragment + ) -> CGPoint { + return CGPoint( + x: basePosition.x + xOffset, + y: basePosition.y + lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) + ) + } + + private func calculateEmphasisRect( + basePosition: CGPoint, + xOffset: CGFloat, + characterIndex: Int, + offset: Int, + drawingContext: InvisibleDrawingContext + ) -> NSRect { + let xEndOffset = if offset + characterIndex + 1 == drawingContext.lineFragment.documentRange.length { + drawingContext.lineFragment.width + } else { + CTLineGetOffsetForStringIndex(drawingContext.ctLine, offset + characterIndex + 1, nil) + } + + return NSRect( + x: basePosition.x + xOffset, + y: basePosition.y, + width: xEndOffset - xOffset, + height: drawingContext.lineFragment.scaledHeight + ) + } + + private func drawReplacementCharacter( + _ replacementCharacter: String, + color: NSColor, + font: NSFont, + at position: CGPoint, + in context: CGContext + ) { + let cacheKey = CacheKey(string: replacementCharacter, font: font, color: color) + let ctLine: CTLine + if let cachedValue = attributedStringCache[cacheKey] { + ctLine = cachedValue + } else { + let attrString = NSAttributedString(string: replacementCharacter, attributes: [ + .font: font, + .foregroundColor: color + ]) + ctLine = CTLineCreateWithAttributedString(attrString) + attributedStringCache[cacheKey] = ctLine + } + context.textPosition = position + CTLineDraw(ctLine, context) + } + + private func drawEmphasis( + color: NSColor, + forRect: NSRect, + in context: CGContext + ) { + context.setFillColor(color.cgColor) + + let rect: CGRect + + if forRect.width == 0 { + // Zero-width character, add padding + rect = CGRect(x: forRect.origin.x - 2, y: forRect.origin.y, width: 4, height: forRect.height) + } else { + rect = forRect + } + + context.fill(rect) + } +} diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 043c1829e..66af42872 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -6,60 +6,85 @@ // import AppKit -import CodeEditTextViewObjC /// Displays a line fragment. -final class LineFragmentView: NSView { - private weak var lineFragment: LineFragment? +open class LineFragmentView: NSView { + public weak var lineFragment: LineFragment? + public weak var renderer: LineFragmentRenderer? +#if DEBUG_LINE_INVALIDATION + private var backgroundAnimation: CABasicAnimation? +#endif - override var isFlipped: Bool { + open override var isFlipped: Bool { true } - override var isOpaque: Bool { + open override var isOpaque: Bool { false } - override func hitTest(_ point: NSPoint) -> NSView? { nil } + open override func hitTest(_ point: NSPoint) -> NSView? { nil } - /// Prepare the view for reuse, clears the line fragment reference. - override func prepareForReuse() { + public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + required public init?(coder: NSCoder) { + super.init(coder: coder) + } + +#if DEBUG_LINE_INVALIDATION + /// Setup background animation from random color to clear when this fragment is invalidated. + private func setupBackgroundAnimation() { + self.wantsLayer = true + + let randomColor = NSColor( + red: CGFloat.random(in: 0...1), + green: CGFloat.random(in: 0...1), + blue: CGFloat.random(in: 0...1), + alpha: 0.3 + ) + + self.layer?.backgroundColor = randomColor.cgColor + + let animation = CABasicAnimation(keyPath: "backgroundColor") + animation.fromValue = randomColor.cgColor + animation.toValue = NSColor.clear.cgColor + animation.duration = 1.0 + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + animation.fillMode = .forwards + animation.isRemovedOnCompletion = false + self.layer?.add(animation, forKey: "backgroundColorAnimation") + + DispatchQueue.main.asyncAfter(deadline: .now() + animation.duration) { + self.layer?.backgroundColor = NSColor.clear.cgColor + } + } +#endif + + open override func prepareForReuse() { super.prepareForReuse() lineFragment = nil + +#if DEBUG_LINE_INVALIDATION + setupBackgroundAnimation() +#endif } /// Set a new line fragment for this view, updating view size. /// - Parameter newFragment: The new fragment to use. - public func setLineFragment(_ newFragment: LineFragment) { + open func setLineFragment(_ newFragment: LineFragment, fragmentRange: NSRange, renderer: LineFragmentRenderer) { self.lineFragment = newFragment + self.renderer = renderer self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight) } /// Draws the line fragment in the graphics context. - override func draw(_ dirtyRect: NSRect) { + open override func draw(_ dirtyRect: NSRect) { guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() - - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - context.setAllowsFontSmoothing(false) - context.setShouldSmoothFonts(false) - context.setAllowsFontSubpixelPositioning(true) - context.setShouldSubpixelPositionFonts(true) - context.setAllowsFontSubpixelQuantization(true) - context.setShouldSubpixelQuantizeFonts(true) - - ContextSetHiddenSmoothingStyle(context, 16) - - context.textMatrix = .init(scaleX: 1, y: -1) - context.textPosition = CGPoint( - x: 0, - y: lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) - ).pixelAligned - - CTLineDraw(lineFragment.ctLine, context) - context.restoreGState() + + renderer?.draw(lineFragment: lineFragment, in: context, yPos: 0.0) } } diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 0bce6307b..b9038a6f3 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -31,7 +31,13 @@ public final class TextLine: Identifiable, Equatable { /// - Returns: True, if this line has been marked as needing layout using ``TextLine/setNeedsLayout()`` or if the /// line needs to find new line breaks due to a new constraining width. func needsLayout(maxWidth: CGFloat) -> Bool { - needsLayout || maxWidth != self.maxWidth + needsLayout // Force layout + || ( + // Both max widths we're comparing are finite + maxWidth.isFinite + && (self.maxWidth ?? 0.0).isFinite + && maxWidth != (self.maxWidth ?? 0.0) + ) } /// Prepares the line for display, generating all potential line breaks and calculating the real height of the line. @@ -40,22 +46,23 @@ public final class TextLine: Identifiable, Equatable { /// - range: The range this text range represents in the entire document. /// - stringRef: A reference to the string storage for the document. /// - markedRanges: Any marked ranges in the line. - /// - breakStrategy: Determines how line breaks are calculated. - func prepareForDisplay( + /// - attachments: Any attachments overlapping the line range. + public func prepareForDisplay( displayData: DisplayData, range: NSRange, stringRef: NSTextStorage, - markedRanges: MarkedTextManager.MarkedRanges?, - breakStrategy: LineBreakStrategy + markedRanges: MarkedRanges?, + attachments: [AnyTextAttachment] ) { let string = stringRef.attributedSubstring(from: range) - self.maxWidth = displayData.maxWidth - typesetter.typeset( + let maxWidth = typesetter.typeset( string, + documentRange: range, displayData: displayData, - breakStrategy: breakStrategy, - markedRanges: markedRanges + markedRanges: markedRanges, + attachments: attachments ) + self.maxWidth = displayData.maxWidth needsLayout = false } @@ -64,9 +71,22 @@ public final class TextLine: Identifiable, Equatable { } /// Contains all required data to perform a typeset and layout operation on a text line. - struct DisplayData { - let maxWidth: CGFloat - let lineHeightMultiplier: CGFloat - let estimatedLineHeight: CGFloat + public struct DisplayData { + public let maxWidth: CGFloat + public let lineHeightMultiplier: CGFloat + public let estimatedLineHeight: CGFloat + public let breakStrategy: LineBreakStrategy + + public init( + maxWidth: CGFloat, + lineHeightMultiplier: CGFloat, + estimatedLineHeight: CGFloat, + breakStrategy: LineBreakStrategy = .character + ) { + self.maxWidth = maxWidth + self.lineHeightMultiplier = lineHeightMultiplier + self.estimatedLineHeight = estimatedLineHeight + self.breakStrategy = breakStrategy + } } } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter.swift deleted file mode 100644 index f05c69c75..000000000 --- a/Sources/CodeEditTextView/TextLine/Typesetter.swift +++ /dev/null @@ -1,240 +0,0 @@ -// -// Typesetter.swift -// CodeEditTextView -// -// Created by Khan Winter on 6/21/23. -// - -import Foundation -import CoreText - -final class Typesetter { - var typesetter: CTTypesetter? - var string: NSAttributedString! - var lineFragments = TextLineStorage() - - // MARK: - Init & Prepare - - init() { } - - func typeset( - _ string: NSAttributedString, - displayData: TextLine.DisplayData, - breakStrategy: LineBreakStrategy, - markedRanges: MarkedTextManager.MarkedRanges? - ) { - lineFragments.removeAll() - if let markedRanges { - let mutableString = NSMutableAttributedString(attributedString: string) - for markedRange in markedRanges.ranges { - mutableString.addAttributes(markedRanges.attributes, range: markedRange) - } - self.string = mutableString - } else { - self.string = string - } - self.typesetter = CTTypesetterCreateWithAttributedString(self.string) - generateLines( - maxWidth: displayData.maxWidth, - lineHeightMultiplier: displayData.lineHeightMultiplier, - estimatedLineHeight: displayData.estimatedLineHeight, - breakStrategy: breakStrategy - ) - } - - // MARK: - Generate lines - - /// Generate line fragments. - /// - Parameters: - /// - maxWidth: The maximum width the line can be. - /// - lineHeightMultiplier: The multiplier to apply to an empty line's height. - /// - estimatedLineHeight: The estimated height of an empty line. - private func generateLines( - maxWidth: CGFloat, - lineHeightMultiplier: CGFloat, - estimatedLineHeight: CGFloat, - breakStrategy: LineBreakStrategy - ) { - guard let typesetter else { return } - var lines: [TextLineStorage.BuildItem] = [] - var height: CGFloat = 0 - if string.length == 0 { - // Insert an empty fragment - let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) - let fragment = LineFragment( - ctLine: ctLine, - width: 0, - height: estimatedLineHeight/lineHeightMultiplier, - descent: 0, - lineHeightMultiplier: lineHeightMultiplier - ) - lines = [.init(data: fragment, length: 0, height: fragment.scaledHeight)] - } else { - var startIndex = 0 - while startIndex < string.length { - let lineBreak = suggestLineBreak( - using: typesetter, - strategy: breakStrategy, - startingOffset: startIndex, - constrainingWidth: maxWidth - ) - let lineFragment = typesetLine( - range: NSRange(start: startIndex, end: lineBreak), - lineHeightMultiplier: lineHeightMultiplier - ) - lines.append(.init( - data: lineFragment, - length: lineBreak - startIndex, - height: lineFragment.scaledHeight - )) - startIndex = lineBreak - height = lineFragment.scaledHeight - } - } - // Use an efficient tree building algorithm rather than adding lines sequentially - lineFragments.build(from: lines, estimatedLineHeight: height) - } - - /// Typeset a new fragment. - /// - Parameters: - /// - range: The range of the fragment. - /// - lineHeightMultiplier: The multiplier to apply to the line's height. - /// - Returns: A new line fragment. - private func typesetLine(range: NSRange, lineHeightMultiplier: CGFloat) -> LineFragment { - let ctLine = CTTypesetterCreateLine(typesetter!, CFRangeMake(range.location, range.length)) - var ascent: CGFloat = 0 - var descent: CGFloat = 0 - var leading: CGFloat = 0 - let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading)) - let height = ascent + descent + leading - return LineFragment( - ctLine: ctLine, - width: width, - height: height, - descent: descent, - lineHeightMultiplier: lineHeightMultiplier - ) - } - - // MARK: - Line Breaks - - /// Suggest a line break for the given line break strategy. - /// - Parameters: - /// - typesetter: The typesetter to use. - /// - strategy: The strategy that determines a valid line break. - /// - startingOffset: Where to start breaking. - /// - constrainingWidth: The available space for the line. - /// - Returns: An offset relative to the entire string indicating where to break. - private func suggestLineBreak( - using typesetter: CTTypesetter, - strategy: LineBreakStrategy, - startingOffset: Int, - constrainingWidth: CGFloat - ) -> Int { - switch strategy { - case .character: - return suggestLineBreakForCharacter( - using: typesetter, - startingOffset: startingOffset, - constrainingWidth: constrainingWidth - ) - case .word: - return suggestLineBreakForWord( - using: typesetter, - startingOffset: startingOffset, - constrainingWidth: constrainingWidth - ) - } - } - - /// Suggest a line break for the character break strategy. - /// - Parameters: - /// - typesetter: The typesetter to use. - /// - startingOffset: Where to start breaking. - /// - constrainingWidth: The available space for the line. - /// - Returns: An offset relative to the entire string indicating where to break. - private func suggestLineBreakForCharacter( - using typesetter: CTTypesetter, - startingOffset: Int, - constrainingWidth: CGFloat - ) -> Int { - var breakIndex: Int - breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth) - guard breakIndex < string.length else { - return breakIndex - } - let substring = string.attributedSubstring(from: NSRange(location: breakIndex - 1, length: 2)).string - if substring == LineEnding.carriageReturnLineFeed.rawValue { - // Breaking in the middle of the clrf line ending - return breakIndex + 1 - } - return breakIndex - } - - /// Suggest a line break for the word break strategy. - /// - Parameters: - /// - typesetter: The typesetter to use. - /// - startingOffset: Where to start breaking. - /// - constrainingWidth: The available space for the line. - /// - Returns: An offset relative to the entire string indicating where to break. - private func suggestLineBreakForWord( - using typesetter: CTTypesetter, - startingOffset: Int, - constrainingWidth: CGFloat - ) -> Int { - var breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth) - - let isBreakAtEndOfString = breakIndex >= string.length - - let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex) - if isNextCharacterCarriageReturn { - breakIndex += 1 - } - - let canLastCharacterBreak = (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1)) - - if isBreakAtEndOfString || canLastCharacterBreak { - // Breaking either at the end of the string, or on a whitespace. - return breakIndex - } else if breakIndex - 1 > 0 { - // Try to walk backwards until we hit a whitespace or punctuation - var index = breakIndex - 1 - - while breakIndex - index < 100 && index > startingOffset { - if ensureCharacterCanBreakLine(at: index) { - return index + 1 - } - index -= 1 - } - } - - return breakIndex - } - - /// Ensures the character at the given index can break a line. - /// - Parameter index: The index to check at. - /// - Returns: True, if the character is a whitespace or punctuation character. - private func ensureCharacterCanBreakLine(at index: Int) -> Bool { - let set = CharacterSet( - charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string - ) - return set.isSubset(of: .whitespacesAndNewlines) || set.isSubset(of: .punctuationCharacters) - } - - /// Check if the break index is on a CRLF (`\r\n`) character, indicating a valid break position. - /// - Parameter breakIndex: The index to check in the string. - /// - Returns: True, if the break index lies after the `\n` character in a `\r\n` sequence. - private func checkIfLineBreakOnCRLF(_ breakIndex: Int) -> Bool { - guard breakIndex - 1 > 0 && breakIndex + 1 <= string.length else { - return false - } - let substringRange = NSRange(location: breakIndex - 1, length: 2) - let substring = string.attributedSubstring(from: substringRange).string - - return substring == LineEnding.carriageReturnLineFeed.rawValue - } - - deinit { - lineFragments.removeAll() - } -} diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift b/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift new file mode 100644 index 000000000..7466a9e5d --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift @@ -0,0 +1,16 @@ +// +// CTLineTypesetData.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import AppKit + +/// Represents layout information received from a `CTTypesetter` for a `CTLine`. +struct CTLineTypesetData { + let ctLine: CTLine + let descent: CGFloat + let width: CGFloat + let height: CGFloat +} diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift new file mode 100644 index 000000000..f6bb487b2 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift @@ -0,0 +1,24 @@ +// +// LineFragmentTypesetContext.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import CoreGraphics + +/// Represents partial parsing state for typesetting a line fragment. Used once during typesetting and then discarded. +struct LineFragmentTypesetContext { + var contents: [LineFragment.FragmentContent] = [] + var start: Int + var width: CGFloat + var height: CGFloat + var descent: CGFloat + + mutating func clear() { + contents.removeAll(keepingCapacity: true) + width = 0 + height = 0 + descent = 0 + } +} diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift new file mode 100644 index 000000000..f5b6ab6df --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -0,0 +1,78 @@ +// +// TypesetContext.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import Foundation + +/// Represents partial parsing state for typesetting a line. Used once during typesetting and then discarded. +/// Contains a few methods for appending data or popping the current line data. +struct TypesetContext { + let documentRange: NSRange + let displayData: TextLine.DisplayData + + /// Accumulated generated line fragments. + var lines: [TextLineStorage.BuildItem] = [] + var maxHeight: CGFloat = 0 + /// The current fragment typesetting context. + var fragmentContext = LineFragmentTypesetContext(start: 0, width: 0.0, height: 0.0, descent: 0.0) + + /// Tracks the current position when laying out runs + var currentPosition: Int = 0 + + // MARK: - Fragment Context Modification + + /// Appends an attachment to the current ``fragmentContext`` + /// - Parameter attachment: The type-erased attachment to append. + mutating func appendAttachment(_ attachment: AnyTextAttachment) { + // Check if we can append this attachment to the current line + if fragmentContext.width + attachment.width > displayData.maxWidth { + popCurrentData() + } + + // Add the attachment to the current line + fragmentContext.contents.append( + .init(data: .attachment(attachment: attachment), width: attachment.width) + ) + fragmentContext.width += attachment.width + fragmentContext.height = fragmentContext.height == 0 ? maxHeight : fragmentContext.height + currentPosition += attachment.range.length + } + + /// Appends a text range to the current ``fragmentContext`` + /// - Parameters: + /// - typesettingRange: The range relative to the typesetter for the current fragment context. + /// - lineBreak: The position that the text fragment should end at, relative to the typesetter's range. + /// - typesetData: Data received from the typesetter. + mutating func appendText(typesettingRange: NSRange, lineBreak: Int, typesetData: CTLineTypesetData) { + fragmentContext.contents.append( + .init(data: .text(line: typesetData.ctLine), width: typesetData.width) + ) + fragmentContext.width += typesetData.width + fragmentContext.height = typesetData.height + fragmentContext.descent = max(typesetData.descent, fragmentContext.descent) + currentPosition = lineBreak + typesettingRange.location + } + + // MARK: - Pop Fragments + + /// Pop the current fragment state into a new line fragment, and reset the fragment state. + mutating func popCurrentData() { + let fragment = LineFragment( + contents: fragmentContext.contents, + width: fragmentContext.width, + height: fragmentContext.height, + descent: fragmentContext.descent, + lineHeightMultiplier: displayData.lineHeightMultiplier + ) + lines.append( + .init(data: fragment, length: currentPosition - fragmentContext.start, height: fragment.scaledHeight) + ) + maxHeight = max(maxHeight, fragment.scaledHeight) + + fragmentContext.clear() + fragmentContext.start = currentPosition + } +} diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift new file mode 100644 index 000000000..b5edb8594 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -0,0 +1,248 @@ +// +// Typesetter.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/21/23. +// + +import AppKit +import CoreText + +/// The `Typesetter` is responsible for producing text fragments from a document range. It transforms a text line +/// and attachments into a sequence of `LineFragment`s, which reflect the visual structure of the text line. +/// +/// This class has one primary method: ``typeset(_:documentRange:displayData:markedRanges:attachments:)``, which +/// performs the typesetting algorithm and breaks content into runs using attachments. +/// +/// To retrieve the line fragments generated by this class, access the ``lineFragments`` property. +final public class Typesetter { + struct ContentRun { + let range: NSRange + let type: RunType + + enum RunType { + case attachment(AnyTextAttachment) + case string(CTTypesetter) + } + } + + public var documentRange: NSRange? + public var lineFragments = TextLineStorage() + + // MARK: - Init & Prepare + + public init() { } + + public func typeset( + _ string: NSAttributedString, + documentRange: NSRange, + displayData: TextLine.DisplayData, + markedRanges: MarkedRanges?, + attachments: [AnyTextAttachment] = [] + ) { + let string = makeString(string: string, markedRanges: markedRanges) + lineFragments.removeAll() + + // Fast path + if string.length == 0 || displayData.maxWidth <= 0 { + typesetEmptyLine(displayData: displayData, string: string) + return + } + let (lines, maxHeight) = typesetLineFragments( + string: string, + documentRange: documentRange, + displayData: displayData, + attachments: attachments + ) + lineFragments.build(from: lines, estimatedLineHeight: maxHeight) + } + + private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) -> NSAttributedString { + if let markedRanges { + let mutableString = NSMutableAttributedString(attributedString: string) + for markedRange in markedRanges.ranges { + mutableString.addAttributes(markedRanges.attributes, range: markedRange) + } + return mutableString + } + + return string + } + + // MARK: - Create Content Lines + + /// Breaks up the string into a series of 'runs' making up the visual content of this text line. + /// - Parameters: + /// - string: The string reference to use. + /// - documentRange: The range in the string reference. + /// - attachments: Any text attachments overlapping the string reference. + /// - Returns: A series of content runs making up this line. + func createContentRuns( + string: NSAttributedString, + documentRange: NSRange, + attachments: [AnyTextAttachment] + ) -> [ContentRun] { + var attachments = attachments + var currentPosition = 0 + let maxPosition = documentRange.length + var runs: [ContentRun] = [] + + while currentPosition < maxPosition { + guard let nextAttachment = attachments.first else { + // No attachments, use the remaining length + if maxPosition > currentPosition { + let range = NSRange(location: currentPosition, length: maxPosition - currentPosition) + let substring = string.attributedSubstring(from: range) + runs.append( + ContentRun( + range: range, + type: .string(CTTypesetterCreateWithAttributedString(substring)) + ) + ) + } + break + } + attachments.removeFirst() + // adjust the range to be relative to the line + let attachmentRange = NSRange( + location: nextAttachment.range.location - documentRange.location, + length: nextAttachment.range.length + ) + + // Use the space before the attachment + if nextAttachment.range.location > currentPosition { + let range = NSRange(start: currentPosition, end: attachmentRange.location) + let substring = string.attributedSubstring(from: range) + runs.append( + ContentRun(range: range, type: .string(CTTypesetterCreateWithAttributedString(substring))) + ) + } + + runs.append(ContentRun(range: attachmentRange, type: .attachment(nextAttachment))) + currentPosition = attachmentRange.max + } + + return runs + } + + // MARK: - Typeset Content Runs + + func typesetLineFragments( + string: NSAttributedString, + documentRange: NSRange, + displayData: TextLine.DisplayData, + attachments: [AnyTextAttachment] + ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) { + let contentRuns = createContentRuns(string: string, documentRange: documentRange, attachments: attachments) + var context = TypesetContext(documentRange: documentRange, displayData: displayData) + + for run in contentRuns { + switch run.type { + case .attachment(let attachment): + context.appendAttachment(attachment) + case .string(let typesetter): + layoutTextUntilLineBreak( + context: &context, + string: string, + range: run.range, + typesetter: typesetter, + displayData: displayData + ) + } + } + + if !context.fragmentContext.contents.isEmpty { + context.popCurrentData() + } + + return (context.lines, context.maxHeight) + } + + // MARK: - Layout Text Fragments + + func layoutTextUntilLineBreak( + context: inout TypesetContext, + string: NSAttributedString, + range: NSRange, + typesetter: CTTypesetter, + displayData: TextLine.DisplayData + ) { + let substring = string.attributedSubstring(from: range) + + // Layout as many fragments as possible in this content run + while context.currentPosition < range.max { + // The line break indicates the distance from the range we’re typesetting on that should be broken at. + // It's relative to the range being typeset, not the line + let lineBreak = typesetter.suggestLineBreak( + using: substring, + strategy: displayData.breakStrategy, + subrange: NSRange(start: context.currentPosition - range.location, end: range.length), + constrainingWidth: displayData.maxWidth - context.fragmentContext.width + ) + + // Indicates the subrange on the range that the typesetter knows about. This may not be the entire line + let typesetSubrange = NSRange(location: context.currentPosition - range.location, length: lineBreak) + let typesetData = typesetLine(typesetter: typesetter, range: typesetSubrange) + + // The typesetter won't tell us if 0 characters can fit in the constrained space. This checks to + // make sure we can fit something. If not, we pop and continue + if lineBreak == 1 && context.fragmentContext.width + typesetData.width > displayData.maxWidth { + context.popCurrentData() + continue + } + + // Amend the current line data to include this line, popping the current line afterwards + context.appendText(typesettingRange: range, lineBreak: lineBreak, typesetData: typesetData) + + // If this isn't the end of the line, we should break so we pop the context and start a new fragment. + if context.currentPosition != range.max { + context.popCurrentData() + } + } + } + + // MARK: - Typeset CTLines + + /// Typeset a new fragment. + /// - Parameters: + /// - range: The range of the fragment. + /// - lineHeightMultiplier: The multiplier to apply to the line's height. + /// - Returns: A new line fragment. + private func typesetLine(typesetter: CTTypesetter, range: NSRange) -> CTLineTypesetData { + let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(range.location, range.length)) + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + var leading: CGFloat = 0 + let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading)) + let height = ascent + descent + leading + return CTLineTypesetData( + ctLine: ctLine, + descent: descent, + width: width, + height: height + ) + } + + /// Typesets a single, 0-length line fragment. + /// - Parameter displayData: Relevant information for layout estimation. + private func typesetEmptyLine(displayData: TextLine.DisplayData, string: NSAttributedString) { + let typesetter = CTTypesetterCreateWithAttributedString(string) + // Insert an empty fragment + let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) + let fragment = LineFragment( + contents: [.init(data: .text(line: ctLine), width: 0.0)], + width: 0, + height: displayData.estimatedLineHeight / displayData.lineHeightMultiplier, + descent: 0, + lineHeightMultiplier: displayData.lineHeightMultiplier + ) + lineFragments.build( + from: [.init(data: fragment, length: 0, height: fragment.scaledHeight)], + estimatedLineHeight: 0 + ) + } + + deinit { + lineFragments.removeAll() + } +} diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift index a7c4b7eb7..c79d4adcb 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift @@ -7,11 +7,24 @@ import Foundation +/// # Dev Note +/// +/// For these iterators, prefer `.getLine(atIndex: )` for finding the next item in the iteration. +/// Using plain indexes instead of y positions or ranges has led to far fewer edge cases. public extension TextLineStorage { + /// Iterate over all lines overlapping a range of `y` positions. Positions in the middle of line contents will + /// return that line. + /// - Parameters: + /// - minY: The minimum y position to start at. + /// - maxY: The maximum y position to stop at. + /// - Returns: A lazy iterator for retrieving lines. func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator { TextLineStorageYIterator(storage: self, minY: minY, maxY: maxY) } + /// Iterate over all lines overlapping a range in the document. + /// - Parameter range: The range to query. + /// - Returns: A lazy iterator for retrieving lines. func linesInRange(_ range: NSRange) -> TextLineStorageRangeIterator { TextLineStorageRangeIterator(storage: self, range: range) } @@ -36,7 +49,7 @@ public extension TextLineStorage { return nil } self.currentPosition = nextPosition - return self.currentPosition! + return nextPosition } else if let nextPosition = storage.getLine(atPosition: minY) { self.currentPosition = nextPosition return nextPosition @@ -60,11 +73,11 @@ public extension TextLineStorage { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.range.max < range.max, - let nextPosition = storage.getLine(atOffset: currentPosition.range.max) else { + let nextPosition = storage.getLine(atIndex: currentPosition.index + 1) else { return nil } self.currentPosition = nextPosition - return self.currentPosition! + return nextPosition } else if let nextPosition = storage.getLine(atOffset: range.location) { self.currentPosition = nextPosition return nextPosition @@ -92,11 +105,11 @@ extension TextLineStorage: LazySequenceProtocol { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.range.max < storage.length, - let nextPosition = storage.getLine(atOffset: currentPosition.range.max) else { + let nextPosition = storage.getLine(atIndex: currentPosition.index + 1) else { return nil } self.currentPosition = nextPosition - return self.currentPosition! + return nextPosition } else if let nextPosition = storage.getLine(atOffset: 0) { self.currentPosition = nextPosition return nextPosition diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift index 55a78d5cd..f944fae30 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift @@ -58,6 +58,13 @@ public final class TextLineStorage { public init() { } + init(root: Node, count: Int, length: Int, height: CGFloat) { + self.root = root + self.count = count + self.length = length + self.height = height + } + // MARK: - Public Methods /// Inserts a new line for the given range. @@ -190,26 +197,29 @@ public final class TextLineStorage { /// - Complexity `O(m log n)` where `m` is the number of lines that need to be deleted as a result of this update. /// and `n` is the number of lines stored in the tree. /// - Parameters: - /// - index: The index where the edit began + /// - offset: The offset where the edit began /// - delta: The change in length of the document. Negative for deletes, positive for insertions. /// - deltaHeight: The change in height of the document. - public func update(atIndex index: Int, delta: Int, deltaHeight: CGFloat) { - assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") + public func update(atOffset offset: Int, delta: Int, deltaHeight: CGFloat) { + assert( + offset >= 0 && offset <= self.length, + "Invalid index, expected between 0 and \(self.length). Got \(offset)" + ) assert(delta != 0 || deltaHeight != 0, "Delta must be non-0") let position: NodePosition? - if index == self.length { // Updates at the end of the document are valid + if offset == self.length { // Updates at the end of the document are valid position = lastNode } else { - position = search(for: index) + position = search(for: offset) } guard let position else { - assertionFailure("No line found at index \(index)") + assertionFailure("No line found at index \(offset)") return } if delta < 0 { assert( - index - position.textPos > delta, - "Delta too large. Deleting \(-delta) from line at position \(index) extends beyond the line's range." + offset - position.textPos > delta, + "Delta too large. Deleting \(-delta) from line at position \(offset) extends beyond the line's range." ) } length += delta @@ -405,9 +415,9 @@ private extension TextLineStorage { } else { transplant(nodeY, with: nodeY.right) - nodeY.right?.leftSubtreeCount = nodeY.leftSubtreeCount - nodeY.right?.leftSubtreeHeight = nodeY.leftSubtreeHeight - nodeY.right?.leftSubtreeOffset = nodeY.leftSubtreeOffset + nodeY.right?.leftSubtreeCount += nodeY.leftSubtreeCount + nodeY.right?.leftSubtreeHeight += nodeY.leftSubtreeHeight + nodeY.right?.leftSubtreeOffset += nodeY.leftSubtreeOffset nodeY.right = nodeZ.right nodeY.right?.parent = nodeY diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift index d12033377..73e40325e 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift @@ -109,10 +109,11 @@ package extension TextSelectionManager { if hasFoundValidWordChar && CharacterSet.punctuationCharacters .union(.whitespacesAndNewlines) + .subtracting(CharacterSet.codeIdentifierCharacters) .isSuperset(of: CharacterSet(charactersIn: substring)) { stop.pointee = true return - } else if CharacterSet.alphanumerics.isSuperset(of: CharacterSet(charactersIn: substring)) { + } else if CharacterSet.codeIdentifierCharacters.isSuperset(of: CharacterSet(charactersIn: substring)) { hasFoundValidWordChar = true } rangeToDelete.length += substring.count diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift index d94563a7b..eb7e8a349 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift @@ -25,36 +25,46 @@ public extension TextSelectionManager { decomposeCharacters: Bool = false, suggestedXPos: CGFloat? = nil ) -> NSRange { + var range: NSRange switch direction { case .backward: guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0 - return extendSelectionHorizontal( + range = extendSelectionHorizontal( from: offset, destination: destination, delta: -1, decomposeCharacters: decomposeCharacters ) case .forward: - return extendSelectionHorizontal( + range = extendSelectionHorizontal( from: offset, destination: destination, delta: 1, decomposeCharacters: decomposeCharacters ) case .up: - return extendSelectionVertical( + range = extendSelectionVertical( from: offset, destination: destination, up: true, suggestedXPos: suggestedXPos ) case .down: - return extendSelectionVertical( + range = extendSelectionVertical( from: offset, destination: destination, up: false, suggestedXPos: suggestedXPos ) } + + // Extend ranges to include attachments. + if let attachments = layoutManager?.attachments.getAttachmentsOverlapping(range) { + attachments.forEach { textAttachment in + range.formUnion(textAttachment.range) + } + } + + return range } } diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift index e79832fdc..b24dab062 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift @@ -10,10 +10,10 @@ import AppKit extension TextSelectionManager { /// Draws line backgrounds and selection rects for each selection in the given rect. /// - Parameter rect: The rect to draw in. - func drawSelections(in rect: NSRect) { + public func drawSelections(in rect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() - var highlightedLines: Set = [] + var highlightedLines: Set = [] // For each selection in the rect for textSelection in textSelections { if textSelection.range.isEmpty { @@ -41,7 +41,7 @@ extension TextSelectionManager { in rect: NSRect, for textSelection: TextSelection, context: CGContext, - highlightedLines: inout Set + highlightedLines: inout Set ) { guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location), !highlightedLines.contains(linePosition.data.id) else { @@ -82,13 +82,7 @@ extension TextSelectionManager { context.setFillColor(fillColor) let fillRects = getFillRects(in: rect, for: textSelection) - - let minX = fillRects.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0 - let minY = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0 - let max = fillRects.max(by: { $0.maxY < $1.maxY }) ?? .zero - let origin = CGPoint(x: minX, y: minY) - let size = CGSize(width: max.maxX - minX, height: max.maxY - minY) - textSelection.boundingRect = CGRect(origin: origin, size: size) + textSelection.boundingRect = fillRects.boundingRect() context.fill(fillRects) context.restoreGState() diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index 204e09ebb..f3160bf3e 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -18,7 +18,7 @@ extension TextSelectionManager { /// - Returns: An array of rects that the selection overlaps. func getFillRects(in rect: NSRect, for textSelection: TextSelection) -> [CGRect] { guard let layoutManager, - let range = textSelection.range.intersection(textView?.visibleTextRange ?? .zero) else { + let range = textSelection.range.intersection(delegate?.visibleTextRange ?? .zero) else { return [] } @@ -37,7 +37,7 @@ extension TextSelectionManager { height: rect.height ).intersection(rect) - for linePosition in layoutManager.lineStorage.linesInRange(range) { + for linePosition in layoutManager.linesInRange(range) { fillRects.append( contentsOf: getFillRects(in: validTextDrawingRect, selectionRange: range, forPosition: linePosition) ) @@ -72,10 +72,15 @@ extension TextSelectionManager { } let maxRect: CGRect + let endOfLine = fragmentRange.max <= range.max || range.contains(fragmentRange.max) + let endOfDocument = intersectionRange.max == layoutManager.lineStorage.length + let emptyLine = linePosition.range.isEmpty + // If the selection is at the end of the line, or contains the end of the fragment, and is not the end // of the document, we select the entire line to the right of the selection point. - if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) - && intersectionRange.max != layoutManager.lineStorage.length { + // true, !true = false, false + // true, !true = false, true + if endOfLine && !(endOfDocument && !emptyLine) { maxRect = CGRect( x: rect.maxX, y: fragmentPosition.yPos + linePosition.yPos, diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift index 8ea0c7490..0c319681d 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift @@ -39,8 +39,8 @@ extension TextSelectionManager { } } - func notifyAfterEdit() { - updateSelectionViews() + public func notifyAfterEdit(force: Bool = false) { + updateSelectionViews(force: force) NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } } diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index b6311a9ab..f05168629 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -52,7 +52,7 @@ public class TextSelectionManager: NSObject { weak var delegate: TextSelectionManagerDelegate? var cursorTimer: CursorTimer - init( + public init( layoutManager: TextLayoutManager, textStorage: NSTextStorage, textView: TextView?, @@ -78,15 +78,15 @@ public class TextSelectionManager: NSObject { let selection = TextSelection(range: range) selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX textSelections = [selection] - if textView?.isFirstResponder ?? false { - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } /// Set the selected ranges to new ranges. Overrides any existing selections. /// - Parameter range: The selected ranges to set. public func setSelectedRanges(_ ranges: [NSRange]) { + let oldRanges = textSelections.map(\.range) + textSelections.forEach { $0.view?.removeFromSuperview() } // Remove duplicates, invalid ranges, update suggested X position. textSelections = Set(ranges) @@ -94,13 +94,16 @@ public class TextSelectionManager: NSObject { (0...(textStorage?.length ?? 0)).contains($0.location) && (0...(textStorage?.length ?? 0)).contains($0.max) } + .sorted(by: { $0.location < $1.location }) .map { let selection = TextSelection(range: $0) selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX return selection } - if textView?.isFirstResponder ?? false { - updateSelectionViews() + updateSelectionViews() + delegate?.setNeedsDisplay() + + if oldRanges != textSelections.map(\.range) { NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } } @@ -124,79 +127,90 @@ public class TextSelectionManager: NSObject { } if !didHandle { textSelections.append(newTextSelection) + textSelections.sort(by: { $0.range.location < $1.range.location }) } - if textView?.isFirstResponder ?? false { - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + delegate?.setNeedsDisplay() } // MARK: - Selection Views - /// Update all selection cursors. Placing them in the correct position for each text selection and reseting the - /// blink timer. - func updateSelectionViews() { + /// Update all selection cursors. Placing them in the correct position for each text selection and + /// optionally reseting the blink timer. + func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) { + guard textView?.isFirstResponder ?? false else { return } var didUpdate: Bool = false for textSelection in textSelections { if textSelection.range.isEmpty { - let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin - - var doesViewNeedReposition: Bool - - // If using the system cursor, macOS will change the origin and height by about 0.5, so we do an - // approximate equals in that case to avoid extra updates. - if useSystemCursor, #available(macOS 14.0, *) { - doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorOrigin) - || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) - } else { - doesViewNeedReposition = textSelection.boundingRect.origin != cursorOrigin - || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 - } - - if textSelection.view == nil || doesViewNeedReposition { - let cursorView: NSView + didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection) + } else if !textSelection.range.isEmpty && textSelection.view != nil { + textSelection.view?.removeFromSuperview() + textSelection.view = nil + didUpdate = true + } + } - if let existingCursorView = textSelection.view { - cursorView = existingCursorView - } else { - textSelection.view?.removeFromSuperview() - textSelection.view = nil + if didUpdate || force { + delegate?.setNeedsDisplay() + if !skipTimerReset { + cursorTimer.resetTimer() + resetSystemCursorTimers() + } + } + } - if useSystemCursor, #available(macOS 14.0, *) { - let systemCursorView = NSTextInsertionIndicator(frame: .zero) - cursorView = systemCursorView - systemCursorView.displayMode = .automatic - } else { - let internalCursorView = CursorView(color: insertionPointColor) - cursorView = internalCursorView - cursorTimer.register(internalCursorView) - } + private func repositionCursorSelection(textSelection: TextSelection) -> Bool { + guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else { + return false + } - textView?.addSubview(cursorView) - } + var doesViewNeedReposition: Bool - cursorView.frame.origin = cursorOrigin - cursorView.frame.size.height = heightForCursorAt(textSelection.range) ?? 0 + // If using the system cursor, macOS will change the origin and height by about 0.5, so we do an + // approximate equals in that case to avoid extra updates. + if useSystemCursor, #available(macOS 14.0, *) { + doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin) + || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) + } else { + doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin + || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 + } - textSelection.view = cursorView - textSelection.boundingRect = cursorView.frame + if textSelection.view == nil || doesViewNeedReposition { + let cursorView: NSView - didUpdate = true - } - } else if !textSelection.range.isEmpty && textSelection.view != nil { + if let existingCursorView = textSelection.view { + cursorView = existingCursorView + } else { textSelection.view?.removeFromSuperview() textSelection.view = nil - didUpdate = true + + if useSystemCursor, #available(macOS 14.0, *) { + let systemCursorView = NSTextInsertionIndicator(frame: .zero) + cursorView = systemCursorView + systemCursorView.displayMode = .automatic + } else { + let internalCursorView = CursorView(color: insertionPointColor) + cursorView = internalCursorView + cursorTimer.register(internalCursorView) + } + + textView?.addSubview(cursorView, positioned: .above, relativeTo: nil) } - } - if didUpdate { - delegate?.setNeedsDisplay() - cursorTimer.resetTimer() - resetSystemCursorTimers() + cursorView.frame.origin = cursorRect.origin + cursorView.frame.size.height = cursorRect.height + + textSelection.view = cursorView + textSelection.boundingRect = cursorView.frame + + return true } + + return false } private func resetSystemCursorTimers() { @@ -221,7 +235,6 @@ public class TextSelectionManager: NSObject { .getLine(atOffset: range.location - (selectedLine.range.location))? .height ?? layoutManager?.estimateLineHeight() - } /// Removes all cursor views and stops the cursor blink timer. diff --git a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift new file mode 100644 index 000000000..966b83b8d --- /dev/null +++ b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift @@ -0,0 +1,113 @@ +// +// DraggingTextRenderer.swift +// CodeEditTextView +// +// Created by Khan Winter on 11/24/24. +// + +import AppKit + +class DraggingTextRenderer: NSView { + let ranges: [NSRange] + let layoutManager: TextLayoutManager + + override var isFlipped: Bool { + true + } + + override var intrinsicContentSize: NSSize { + self.frame.size + } + + init?(ranges: [NSRange], layoutManager: TextLayoutManager) { + self.ranges = ranges + self.layoutManager = layoutManager + + assert(!ranges.isEmpty, "Empty ranges not allowed") + + var minY: CGFloat = .infinity + var maxY: CGFloat = 0.0 + + for range in ranges { + for line in layoutManager.lineStorage.linesInRange(range) { + minY = min(minY, line.yPos) + maxY = max(maxY, line.yPos + line.height) + } + } + + let frame = CGRect( + x: layoutManager.edgeInsets.left, + y: minY, + width: layoutManager.maxLineWidth, + height: maxY - minY + ) + + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard let context = NSGraphicsContext.current?.cgContext, + let firstRange = ranges.first, + let minRect = layoutManager.rectForOffset(firstRange.lowerBound) else { + return + } + + for range in ranges { + for line in layoutManager.lineStorage.linesInRange(range) { + drawLine(line, in: range, yOffset: minRect.minY, context: context) + } + } + } + + private func drawLine( + _ line: TextLineStorage.TextLinePosition, + in selectedRange: NSRange, + yOffset: CGFloat, + context: CGContext + ) { + let renderer = LineFragmentRenderer( + textStorage: layoutManager.textStorage, + invisibleCharacterDelegate: layoutManager.invisibleCharacterDelegate + ) + for fragment in line.data.lineFragments { + guard let fragmentRange = fragment.range.shifted(by: line.range.location), + fragmentRange.intersection(selectedRange) != nil else { + continue + } + let fragmentYPos = line.yPos + fragment.yPos - yOffset + renderer.draw(lineFragment: fragment.data, in: context, yPos: fragmentYPos) + + // Clear text that's not selected + if fragmentRange.contains(selectedRange.lowerBound) { + let relativeOffset = selectedRange.lowerBound - line.range.lowerBound + let selectionXPos = layoutManager.characterXPosition(in: fragment.data, for: relativeOffset) + context.clear( + CGRect( + x: 0.0, + y: fragmentYPos, + width: selectionXPos, + height: fragment.height + ).pixelAligned + ) + } + + if fragmentRange.contains(selectedRange.upperBound) { + let relativeOffset = selectedRange.upperBound - line.range.lowerBound + let selectionXPos = layoutManager.characterXPosition(in: fragment.data, for: relativeOffset) + context.clear( + CGRect( + x: selectionXPos, + y: fragmentYPos, + width: frame.width - selectionXPos, + height: fragment.height + ).pixelAligned + ) + } + } + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift b/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift index db8454d52..87ebe2f0a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift @@ -9,11 +9,16 @@ import AppKit /// # Notes /// -/// This implementation considers the entire document as one element, ignoring all subviews and lines. +/// ~~This implementation considers the entire document as one element, ignoring all subviews and lines. /// Another idea would be to make each line fragment an accessibility element, with options for navigating through /// lines from there. The text view would then only handle text input, and lines would handle reading out useful data /// to the user. -/// More research needs to be done for the best option here. +/// More research needs to be done for the best option here.~~ +/// +/// Consider that the system has access to the ``TextView/accessibilityVisibleCharacterRange`` and +/// ``TextView/accessibilityString(for:)`` methods. These can combine to allow an accessibility system to efficiently +/// query the text view's contents. Adding accessibility elements to line fragments would require hit testing them, +/// which will cause performance degradation. extension TextView { override open func isAccessibilityElement() -> Bool { true @@ -27,6 +32,11 @@ extension TextView { isFirstResponder } + override open func setAccessibilityFocused(_ accessibilityFocused: Bool) { + guard !isFirstResponder else { return } + window?.makeFirstResponder(self) + } + override open func accessibilityLabel() -> String? { "Text Editor" } @@ -48,7 +58,11 @@ extension TextView { } override open func accessibilityString(for range: NSRange) -> String? { - textStorage.substring( + guard documentRange.intersection(range) == range else { + return nil + } + + return textStorage.substring( from: textStorage.mutableString.rangeOfComposedCharacterSequences(for: range) ) } @@ -56,13 +70,14 @@ extension TextView { // MARK: Selections override open func accessibilitySelectedText() -> String? { - guard let selection = selectionManager - .textSelections - .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) - .first else { + let selectedRange = accessibilitySelectedTextRange() + guard selectedRange != .notFound else { return nil } - let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selection.range) + if selectedRange.isEmpty { + return "" + } + let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selectedRange) return textStorage.substring(from: range) } @@ -71,7 +86,10 @@ extension TextView { .textSelections .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) .first else { - return .zero + return .notFound + } + if selection.range.isEmpty { + return selection.range } return textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range) } @@ -83,12 +101,10 @@ extension TextView { } override open func accessibilityInsertionPointLineNumber() -> Int { - guard let selection = selectionManager - .textSelections - .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) - .first, - let linePosition = layoutManager.textLineForOffset(selection.range.location) else { - return 0 + let selectedRange = accessibilitySelectedTextRange() + guard selectedRange != .notFound, + let linePosition = layoutManager.textLineForOffset(selectedRange.location) else { + return -1 } return linePosition.index } @@ -122,6 +138,31 @@ extension TextView { } override open func accessibilityRange(for index: Int) -> NSRange { - textStorage.mutableString.rangeOfComposedCharacterSequence(at: index) + guard index < documentRange.length else { return .notFound } + return textStorage.mutableString.rangeOfComposedCharacterSequence(at: index) + } + + override open func accessibilityVisibleCharacterRange() -> NSRange { + visibleTextRange ?? .notFound + } + + /// The line index for a given character offset. + override open func accessibilityLine(for index: Int) -> Int { + guard index <= textStorage.length, + let textLine = layoutManager.textLineForOffset(index) else { + return -1 + } + return textLine.index + } + + override open func accessibilityFrame(for range: NSRange) -> NSRect { + guard documentRange.intersection(range) == range else { + return .zero + } + if range.isEmpty { + return .zero + } + let rects = layoutManager.rectsFor(range: range) + return rects.boundingRect() } } diff --git a/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift b/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift new file mode 100644 index 000000000..ad2e63102 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift @@ -0,0 +1,50 @@ +// +// TextView+ColumnSelection.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/19/25. +// + +import AppKit + +extension TextView { + /// Set the user's selection to a square region in the editor. + /// + /// This method will automatically determine a valid region from the provided two points. + /// - Parameters: + /// - pointA: The first point. + /// - pointB: The second point. + public func selectColumns(betweenPointA pointA: CGPoint, pointB: CGPoint) { + let start = CGPoint(x: min(pointA.x, pointB.x), y: min(pointA.y, pointB.y)) + let end = CGPoint(x: max(pointA.x, pointB.x), y: max(pointA.y, pointB.y)) + + // Collect all overlapping text ranges + var selectedRanges: [NSRange] = layoutManager.linesStartingAt(start.y, until: end.y).flatMap { textLine in + // Collect fragment ranges + return textLine.data.lineFragments.compactMap { lineFragment -> NSRange? in + let startOffset = self.layoutManager.textOffsetAtPoint( + start, + fragmentPosition: lineFragment, + linePosition: textLine + ) + let endOffset = self.layoutManager.textOffsetAtPoint( + end, + fragmentPosition: lineFragment, + linePosition: textLine + ) + guard let startOffset, let endOffset else { return nil } + + return NSRange(start: startOffset, end: endOffset) + } + } + + // If we have some non-cursor selections, filter out any cursor selections + if selectedRanges.contains(where: { !$0.isEmpty }) { + selectedRanges = selectedRanges.filter({ + !$0.isEmpty || (layoutManager.rectForOffset($0.location)?.origin.x.approxEqual(start.x) ?? false) + }) + } + + selectionManager.setSelectedRanges(selectedRanges) + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index f4ac08e84..dbd878a3c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -5,10 +5,16 @@ // Created by Khan Winter on 10/20/23. // +import Foundation import AppKit +private let pasteboardObjects = [NSString.self, NSURL.self] + extension TextView: NSDraggingSource { - class DragSelectionGesture: NSPressGestureRecognizer { + // MARK: - Drag Gesture + + /// Custom press gesture recognizer that fails if it does not click into a selected range. + private class DragSelectionGesture: NSPressGestureRecognizer { override func mouseDown(with event: NSEvent) { guard isEnabled, let view = self.view as? TextView, event.type == .leftMouseDown else { return @@ -26,6 +32,8 @@ extension TextView: NSDraggingSource { } } + /// Adds a gesture for recognizing selection dragging gestures to the text view. + /// See ``TextView/DragSelectionGesture`` for details. func setUpDragGesture() { let dragGesture = DragSelectionGesture(target: self, action: #selector(dragGestureHandler(_:))) dragGesture.minimumPressDuration = NSEvent.doubleClickInterval / 3 @@ -33,47 +41,228 @@ extension TextView: NSDraggingSource { addGestureRecognizer(dragGesture) } - @objc private func dragGestureHandler(_ sender: Any) { - let selectionRects = selectionManager.textSelections.filter({ !$0.range.isEmpty }).flatMap { - selectionManager.getFillRects(in: frame, for: $0) - } - // TODO: This SUcks - let minX = selectionRects.min(by: { $0.minX < $1.minX })?.minX ?? 0.0 - let minY = selectionRects.min(by: { $0.minY < $1.minY })?.minY ?? 0.0 - let maxX = selectionRects.max(by: { $0.maxX < $1.maxX })?.maxX ?? 0.0 - let maxY = selectionRects.max(by: { $0.maxY < $1.maxY })?.maxY ?? 0.0 - let imageBounds = CGRect( - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY - ) + /// Handles state change on the drag and drop gesture recognizer. + /// + /// This will ignore any gesture state besides `.began`, and will end by setting the state to `.ended`. The gesture + /// is only meant to handle *recognizing* the drag, but the system drag interaction handles the rest. + /// + /// This will create a ``DraggingTextRenderer`` with the contents of the visible text selection. That is converted + /// into an image and given to a new dragging session on the text view + /// + /// The rest of the drag interaction is handled by ``performDragOperation(_:)``, ``draggingUpdated(_:)``, + /// ``draggingSession(_:willBeginAt:)`` and family. + /// + /// - Parameter sender: The gesture that's sending the state change. + @objc private func dragGestureHandler(_ sender: DragSelectionGesture) { + guard sender.state == .began else { return } + defer { + sender.state = .ended + } - guard let bitmap = bitmapImageRepForCachingDisplay(in: imageBounds) else { + guard let visibleTextRange, + let draggingView = DraggingTextRenderer( + ranges: selectionManager.textSelections + .sorted(using: KeyPathComparator(\.range.location)) + .compactMap { $0.range.intersection(visibleTextRange) }, + layoutManager: layoutManager + ) else { return } - selectionRects.forEach { selectionRect in - self.cacheDisplay(in: selectionRect, to: bitmap) + guard let bitmap = bitmapImageRepForCachingDisplay(in: draggingView.frame) else { + return + } + + draggingView.cacheDisplay(in: draggingView.bounds, to: bitmap) + + guard let cgImage = bitmap.cgImage else { + return } - let draggingImage = NSImage(cgImage: bitmap.cgImage!, size: imageBounds.size) + let draggingImage = NSImage(cgImage: cgImage, size: draggingView.intrinsicContentSize) - let attributedString = selectionManager + let attributedStrings = selectionManager .textSelections .sorted(by: { $0.range.location < $1.range.location }) .map { textStorage.attributedSubstring(from: $0.range) } - .reduce(NSMutableAttributedString(), { $0.append($1); return $0 }) + let attributedString = NSMutableAttributedString() + for (idx, string) in attributedStrings.enumerated() { + attributedString.append(string) + if idx < attributedStrings.count - 1 { + attributedString.append(NSAttributedString(string: layoutManager.detectedLineEnding.rawValue)) + } + } + let draggingItem = NSDraggingItem(pasteboardWriter: attributedString) - draggingItem.setDraggingFrame(imageBounds, contents: draggingImage) + draggingItem.setDraggingFrame(draggingView.frame, contents: draggingImage) + + guard let currentEvent = NSApp.currentEvent else { + return + } - beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self) + beginDraggingSession(with: [draggingItem], event: currentEvent, source: self) } + // MARK: - NSDraggingSource + public func draggingSession( _ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext ) -> NSDragOperation { context == .outsideApplication ? .copy : .move } + + public func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + if let draggingCursorView { + draggingCursorView.removeFromSuperview() + self.draggingCursorView = nil + } + isDragging = true + setUpMouseAutoscrollTimer() + } + + /// Updates the text view about a dragging session. The text view will update the ``TextView/draggingCursorView`` + /// cursor to match the drop destination depending on where the drag is on the text view. + /// + /// The text view will not place a dragging cursor view when the dragging destination is in an existing + /// text selection. + /// - Parameters: + /// - session: The dragging session that was updated. + /// - screenPoint: The position on the screen where the drag exists. + public func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { + guard let windowCoordinates = self.window?.convertPoint(fromScreen: screenPoint) else { + return + } + + let viewPoint = self.convert(windowCoordinates, from: nil) // Converts from window + let cursor: NSView + + if let draggingCursorView { + cursor = draggingCursorView + } else if useSystemCursor, #available(macOS 15, *) { + let systemCursor = NSTextInsertionIndicator() + cursor = systemCursor + systemCursor.displayMode = .visible + addSubview(cursor) + } else { + cursor = CursorView(color: selectionManager.insertionPointColor) + addSubview(cursor) + } + + self.draggingCursorView = cursor + + guard let documentOffset = layoutManager.textOffsetAtPoint(viewPoint), + let cursorPosition = layoutManager.rectForOffset(documentOffset) else { + return + } + + // Don't show a cursor in selected areas + guard !selectionManager.textSelections.contains(where: { $0.range.contains(documentOffset) }) else { + draggingCursorView?.removeFromSuperview() + draggingCursorView = nil + return + } + + cursor.frame.origin = cursorPosition.origin + cursor.frame.size.height = cursorPosition.height + } + + public func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + if let draggingCursorView { + draggingCursorView.removeFromSuperview() + self.draggingCursorView = nil + } + isDragging = false + disableMouseAutoscrollTimer() + } + + override public func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + determineDragOperation(sender) + } + + override public func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + determineDragOperation(sender) + } + + private func determineDragOperation(_ dragInfo: any NSDraggingInfo) -> NSDragOperation { + let canReadObjects = dragInfo.draggingPasteboard.canReadObject(forClasses: pasteboardObjects) + + guard canReadObjects else { + return NSDragOperation() + } + + if let currentEvent = NSApplication.shared.currentEvent, currentEvent.modifierFlags.contains(.option) { + return .copy + } + + return .move + } + + // MARK: - Perform Drag + + /// Performs the final drop operation. + /// + /// This method accepts a number of items from the dragging info's pasteboard, and cuts them into the + /// destination determined by the ``TextView/draggingCursorView``. + /// + /// If the app's current event has the `option` key pressed, this will only paste the text from the pasteboard, + /// and not remove the original dragged text. + /// + /// - Parameter sender: The dragging info to use. + /// - Returns: `true`, if the drag was accepted. + override public func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard let objects = sender.draggingPasteboard.readObjects(forClasses: pasteboardObjects)? + .compactMap({ anyObject in + if let object = anyObject as? NSString { + return String(object) + } else if let object = anyObject as? NSURL, let string = object.absoluteString { + return String(string) + } + return nil + }), + objects.count > 0 else { + return false + } + let insertionString = objects.joined(separator: layoutManager.detectedLineEnding.rawValue) + + // Grab the insertion location + guard let draggingCursorView, + var insertionOffset = layoutManager.textOffsetAtPoint(draggingCursorView.frame.origin) else { + // There was no active drag + return false + } + + let shouldCutSourceText = !(NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false) + + undoManager?.beginUndoGrouping() + + if shouldCutSourceText, let source = sender.draggingSource as? TextView, source === self { + // Offset the insertion location so that we can remove the text first before pasting it into the editor. + var updatedInsertionOffset = insertionOffset + for selection in source.selectionManager.textSelections.reversed() + where selection.range.location < insertionOffset { + if selection.range.upperBound > insertionOffset { + updatedInsertionOffset -= insertionOffset - selection.range.location + } else { + updatedInsertionOffset -= selection.range.length + } + } + insertionOffset = updatedInsertionOffset + insertText("") // Replace the selected ranges with nothing + } + + replaceCharacters(in: [NSRange(location: insertionOffset, length: 0)], with: insertionString) + + undoManager?.endUndoGrouping() + + selectionManager.setSelectedRange( + NSRange(location: insertionOffset, length: NSString(string: insertionString).length) + ) + + return true + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift index 39588a262..968c1ede3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift +++ b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift @@ -51,7 +51,10 @@ extension TextView { open override func resetCursorRects() { super.resetCursorRects() if isSelectable { - addCursorRect(visibleRect, cursor: .iBeam) + addCursorRect( + visibleRect, + cursor: isOptionPressed ? .crosshair : .iBeam + ) } } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Insert.swift b/Sources/CodeEditTextView/TextView/TextView+Insert.swift index 8c4fc408e..05bd092a7 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Insert.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Insert.swift @@ -9,6 +9,21 @@ import AppKit extension TextView { override public func insertNewline(_ sender: Any?) { + var attachments: [AnyTextAttachment] = selectionManager.textSelections.compactMap({ selection in + let content = layoutManager.contentRun(at: selection.range.location) + if case let .attachment(attachment) = content?.data, attachment.range == selection.range { + return attachment + } + return nil + }) + + if !attachments.isEmpty { + for attachment in attachments.sorted(by: { $0.range.location > $1.range.location }) { + performAttachmentAction(attachment: attachment) + } + return + } + insertText(layoutManager.detectedLineEnding.rawValue) } diff --git a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift index 1ef36d4f5..e11187851 100644 --- a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift +++ b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift @@ -47,4 +47,16 @@ extension TextView { return false } + + override public func flagsChanged(with event: NSEvent) { + super.flagsChanged(with: event) + + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let modifierFlagsIsOption = modifierFlags == [.option] + + if modifierFlagsIsOption != isOptionPressed { + isOptionPressed = modifierFlagsIsOption + resetCursorRects() + } + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 038dd349a..2fef2aa1b 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -8,6 +8,12 @@ import Foundation extension TextView { + override public func layout() { + super.layout() + layoutManager.layoutLines() + selectionManager.updateSelectionViews(skipTimerReset: true) + } + open override class var isCompatibleWithResponsiveScrolling: Bool { true } @@ -22,6 +28,7 @@ extension TextView { if isSelectable { selectionManager.drawSelections(in: dirtyRect) } + emphasisManager?.updateLayerBackgrounds() } override open var isFlipped: Bool { @@ -64,7 +71,9 @@ extension TextView { public func updateFrameIfNeeded() -> Bool { var availableSize = scrollView?.contentSize ?? .zero availableSize.height -= (scrollView?.contentInsets.top ?? 0) + (scrollView?.contentInsets.bottom ?? 0) - let newHeight = max(layoutManager.estimatedHeight(), availableSize.height) + + let extraHeight = availableSize.height * overscrollAmount + let newHeight = max(layoutManager.estimatedHeight() + extraHeight, availableSize.height, 0) let newWidth = layoutManager.estimatedWidth() var didUpdate = false diff --git a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift new file mode 100644 index 000000000..812919d0c --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift @@ -0,0 +1,30 @@ +// +// TextView+Lifecycle.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/7/25. +// + +import AppKit + +extension TextView { + override public func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + layoutManager.layoutLines() + } + + override public func viewWillMove(toSuperview newSuperview: NSView?) { + super.viewWillMove(toSuperview: newSuperview) + guard let clipView = newSuperview as? NSClipView, + let scrollView = enclosingScrollView ?? clipView.enclosingScrollView else { + return + } + + setUpScrollListeners(scrollView: scrollView) + } + + override public func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + updateFrameIfNeeded() + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Menu.swift b/Sources/CodeEditTextView/TextView/TextView+Menu.swift index e7f6f7c8d..508f0caf6 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -15,8 +15,8 @@ extension TextView { menu.items = [ NSMenuItem(title: "Cut", action: #selector(cut(_:)), keyEquivalent: "x"), - NSMenuItem(title: "Copy", action: #selector(undo(_:)), keyEquivalent: "c"), - NSMenuItem(title: "Paste", action: #selector(undo(_:)), keyEquivalent: "v") + NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "c"), + NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "v") ] return menu diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 3a4839fe1..0609665f0 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -17,6 +17,12 @@ extension TextView { return } + if let content = layoutManager.contentRun(at: offset), + case let .attachment(attachment) = content.data, event.clickCount < 3 { + handleAttachmentClick(event: event, offset: offset, attachment: attachment) + return + } + switch event.clickCount { case 1: handleSingleClick(event: event, offset: offset) @@ -28,28 +34,24 @@ extension TextView { break } - mouseDragTimer?.invalidate() - // https://cocoadev.github.io/AutoScrolling/ (fired at ~45Hz) - mouseDragTimer = Timer.scheduledTimer(withTimeInterval: 0.022, repeats: true) { [weak self] _ in - if let event = self?.window?.currentEvent, event.type == .leftMouseDragged { - self?.mouseDragged(with: event) - self?.autoscroll(with: event) - } - } + setUpMouseAutoscrollTimer() } /// Single click, if control-shift we add a cursor /// if shift, we extend the selection to the click location /// else we set the cursor fileprivate func handleSingleClick(event: NSEvent, offset: Int) { + cursorSelectionMode = .character + guard isEditable else { super.mouseDown(with: event) return } - if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) { + let eventFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if eventFlags == [.control, .shift] { unmarkText() selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) - } else if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift) { + } else if eventFlags.contains(.shift) { unmarkText() shiftClickExtendSelection(to: offset) } else { @@ -59,6 +61,8 @@ extension TextView { } fileprivate func handleDoubleClick(event: NSEvent) { + cursorSelectionMode = .word + guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) return @@ -68,6 +72,8 @@ extension TextView { } fileprivate func handleTripleClick(event: NSEvent) { + cursorSelectionMode = .line + guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) return @@ -76,33 +82,66 @@ extension TextView { selectLine(nil) } + fileprivate func handleAttachmentClick(event: NSEvent, offset: Int, attachment: AnyTextAttachment) { + switch event.clickCount { + case 1: + selectionManager.setSelectedRange(attachment.range) + case 2: + performAttachmentAction(attachment: attachment) + default: + break + } + } + + func performAttachmentAction(attachment: AnyTextAttachment) { + let action = attachment.attachment.attachmentAction() + switch action { + case .none: + return + case .discard: + layoutManager.attachments.remove(atOffset: attachment.range.location) + selectionManager.setSelectedRange(NSRange(location: attachment.range.location, length: 0)) + case let .replace(text): + replaceCharacters(in: attachment.range, with: text) + } + } + override public func mouseUp(with event: NSEvent) { mouseDragAnchor = nil - mouseDragTimer?.invalidate() - mouseDragTimer = nil + disableMouseAutoscrollTimer() super.mouseUp(with: event) } override public func mouseDragged(with event: NSEvent) { - guard !(inputContext?.handleEvent(event) ?? false) && isSelectable else { + guard !(inputContext?.handleEvent(event) ?? false) && isSelectable && !isDragging else { return } + // We receive global events because our view received the drag event, but we need to clamp the potentially + // out-of-bounds positions to a position our layout manager can deal with. + let locationInWindow = convert(event.locationInWindow, from: nil) + let locationInView = CGPoint( + x: max(0.0, min(locationInWindow.x, frame.width)), + y: max(0.0, min(locationInWindow.y, frame.height)) + ) + if mouseDragAnchor == nil { - mouseDragAnchor = convert(event.locationInWindow, from: nil) + mouseDragAnchor = locationInView super.mouseDragged(with: event) } else { guard let mouseDragAnchor, let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor), - let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else { + let endPosition = layoutManager.textOffsetAtPoint(locationInView) else { return } - selectionManager.setSelectedRange( - NSRange( - location: min(startPosition, endPosition), - length: max(startPosition, endPosition) - min(startPosition, endPosition) - ) - ) + + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if modifierFlags.contains(.option) { + dragColumnSelection(mouseDragAnchor: mouseDragAnchor, locationInView: locationInView) + } else { + dragSelection(startPosition: startPosition, endPosition: endPosition, mouseDragAnchor: mouseDragAnchor) + } + setNeedsDisplay() self.autoscroll(with: event) } @@ -134,4 +173,69 @@ extension TextView { selectionManager.setSelectedRange(selectedRange) setNeedsDisplay() } + + // MARK: - Mouse Autoscroll + + /// Sets up a timer that fires at a predetermined period to autoscroll the text view. + /// Ensure the timer is disabled using ``disableMouseAutoscrollTimer``. + func setUpMouseAutoscrollTimer() { + mouseDragTimer?.invalidate() + // https://cocoadev.github.io/AutoScrolling/ (fired at ~45Hz) + mouseDragTimer = Timer.scheduledTimer(withTimeInterval: 0.022, repeats: true) { [weak self] _ in + if let event = self?.window?.currentEvent, event.type == .leftMouseDragged { + self?.mouseDragged(with: event) + self?.autoscroll(with: event) + } + } + } + + /// Disables the mouse drag timer started by ``setUpMouseAutoscrollTimer`` + func disableMouseAutoscrollTimer() { + mouseDragTimer?.invalidate() + mouseDragTimer = nil + } + + // MARK: - Drag Selection + + private func dragSelection(startPosition: Int, endPosition: Int, mouseDragAnchor: CGPoint) { + switch cursorSelectionMode { + case .character: + selectionManager.setSelectedRange( + NSRange( + location: min(startPosition, endPosition), + length: max(startPosition, endPosition) - min(startPosition, endPosition) + ) + ) + + case .word: + let startWordRange = findWordBoundary(at: startPosition) + let endWordRange = findWordBoundary(at: endPosition) + + selectionManager.setSelectedRange( + NSRange( + location: min(startWordRange.location, endWordRange.location), + length: max(startWordRange.location + startWordRange.length, + endWordRange.location + endWordRange.length) - + min(startWordRange.location, endWordRange.location) + ) + ) + + case .line: + let startLineRange = findLineBoundary(at: startPosition) + let endLineRange = findLineBoundary(at: endPosition) + + selectionManager.setSelectedRange( + NSRange( + location: min(startLineRange.location, endLineRange.location), + length: max(startLineRange.location + startLineRange.length, + endLineRange.location + endLineRange.length) - + min(startLineRange.location, endLineRange.location) + ) + ) + } + } + + private func dragColumnSelection(mouseDragAnchor: CGPoint, locationInView: CGPoint) { + selectColumns(betweenPointA: mouseDragAnchor, pointB: locationInView) + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift index 06949b21a..9a0c7cec3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift @@ -13,19 +13,25 @@ extension TextView { /// - Parameters: /// - ranges: The ranges to replace /// - string: The string to insert in the ranges. - public func replaceCharacters(in ranges: [NSRange], with string: String) { + /// - skipUpdateSelection: Skips the selection update step + public func replaceCharacters( + in ranges: [NSRange], + with string: String, + skipUpdateSelection: Bool = false + ) { guard isEditable else { return } NotificationCenter.default.post(name: Self.textWillChangeNotification, object: self) - layoutManager.beginTransaction() textStorage.beginEditing() + func valid(range: NSRange, string: String) -> Bool { + (!range.isEmpty || !string.isEmpty) && + (delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) + } + // Can't insert an empty string into an empty range. One must be not empty - for range in ranges.sorted(by: { $0.location > $1.location }) where - (!range.isEmpty || !string.isEmpty) && - (delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) { + for range in ranges.sorted(by: { $0.location > $1.location }) where valid(range: range, string: string) { delegate?.textView(self, willReplaceContentsIn: range, with: string) - layoutManager.willReplaceCharactersInRange(range: range, with: string) _undoManager?.registerMutation( TextMutation(string: string as String, range: range, limit: textStorage.length) ) @@ -33,14 +39,18 @@ extension TextView { in: range, with: NSAttributedString(string: string, attributes: typingAttributes) ) - selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length) + if !skipUpdateSelection { + selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length) + } delegate?.textView(self, didReplaceContentsIn: range, with: string) } textStorage.endEditing() - layoutManager.endTransaction() - selectionManager.notifyAfterEdit() + + if !skipUpdateSelection { + selectionManager.notifyAfterEdit() + } NotificationCenter.default.post(name: Self.textDidChangeNotification, object: self) // `scrollSelectionToVisible` is a little expensive to call every time. Instead we just check if the first @@ -54,7 +64,33 @@ extension TextView { /// - Parameters: /// - range: The range to replace. /// - string: The string to insert in the range. - public func replaceCharacters(in range: NSRange, with string: String) { - replaceCharacters(in: [range], with: string) + /// - skipUpdateSelection: Skips the selection update step + public func replaceCharacters( + in range: NSRange, + with string: String, + skipUpdateSelection: Bool = false + ) { + replaceCharacters(in: [range], with: string, skipUpdateSelection: skipUpdateSelection) + } + + /// Iterates over all text selections in the `TextView` and applies the provided callback. + /// + /// This method is typically used when you need to perform an operation on each text selection in the editor, + /// such as adjusting indentation, or other selection-based operations. The callback + /// is executed for each selection, and you can modify the selection or perform related tasks. + /// + /// - Parameters: + /// - callback: A closure that will be executed for each selection in the `TextView`. It takes two parameters: + /// a `TextView` instance, allowing access to the view's properties and methods and a + /// `TextSelectionManager.TextSelection` representing the current selection to operate on. + /// + /// - Note: The selections are iterated in reverse order, so modifications to earlier selections won't affect later + /// ones. The method automatically calls `notifyAfterEdit()` on the `selectionManager` after all + /// selections are processed. + public func editSelections(callback: (TextView, TextSelectionManager.TextSelection) -> Void) { + for textSelection in selectionManager.textSelections.reversed() { + callback(self, textSelection) + } + selectionManager.notifyAfterEdit(force: true) } } diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index 00475ef9f..bc5274ed3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -14,26 +14,26 @@ extension TextView { /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. public func scrollSelectionToVisible() { - guard let scrollView, let selection = getSelection() else { + guard let scrollView else { return } - let offsetToScrollTo = offsetNotPivot(selection) - // There's a bit of a chicken-and-the-egg issue going on here. We need to know the rect to scroll to, but we // can't know the exact rect to make visible without laying out the text. Then, once text is laid out the // selection rect may be different again. To solve this, we loop until the frame doesn't change after a layout // pass and scroll to that rect. var lastFrame: CGRect = .zero - while let boundingRect = layoutManager.rectForOffset(offsetToScrollTo), lastFrame != boundingRect { + while let boundingRect = getSelection()?.boundingRect, lastFrame != boundingRect { lastFrame = boundingRect layoutManager.layoutLines() selectionManager.updateSelectionViews() selectionManager.drawSelections(in: visibleRect) - } - if lastFrame != .zero { - scrollView.contentView.scrollToVisible(lastFrame) + + if lastFrame != .zero { + scrollView.contentView.scrollToVisible(lastFrame) + scrollView.reflectScrolledClipView(scrollView.contentView) + } } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Select.swift b/Sources/CodeEditTextView/TextView/TextView+Select.swift index 6ee70a264..390b6225d 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Select.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Select.swift @@ -29,35 +29,53 @@ extension TextView { override public func selectWord(_ sender: Any?) { let newSelections = selectionManager.textSelections.compactMap { (textSelection) -> NSRange? in - guard textSelection.range.isEmpty, - let char = textStorage.substring( - from: NSRange(location: textSelection.range.location, length: 1) - )?.first else { - return nil - } - let charSet = CharacterSet(charactersIn: String(char)) - let characterSet: CharacterSet - if CharacterSet.alphanumerics.isSuperset(of: charSet) { - characterSet = .alphanumerics - } else if CharacterSet.whitespaces.isSuperset(of: charSet) { - characterSet = .whitespaces - } else if CharacterSet.newlines.isSuperset(of: charSet) { - characterSet = .newlines - } else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) { - characterSet = .punctuationCharacters - } else { - return nil - } - guard let start = textStorage - .findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.location), - let end = textStorage - .findNextOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.max) else { - return nil + guard textSelection.range.isEmpty else { + return nil + } + return findWordBoundary(at: textSelection.range.location) } - return NSRange(start: start, end: end) - } selectionManager.setSelectedRanges(newSelections) unmarkTextIfNeeded() needsDisplay = true } + + /// Given a position, find the range of the word that exists at that position. + internal func findWordBoundary(at position: Int) -> NSRange { + guard position >= 0 && position < textStorage.length, + let char = textStorage.substring( + from: NSRange(location: position, length: 1) + )?.first else { + return NSRange(location: position, length: 0) + } + + let charSet = CharacterSet(charactersIn: String(char)) + let characterSet: CharacterSet + + if CharacterSet.codeIdentifierCharacters.isSuperset(of: charSet) { + characterSet = .codeIdentifierCharacters + } else if CharacterSet.whitespaces.isSuperset(of: charSet) { + characterSet = .whitespaces + } else if CharacterSet.newlines.isSuperset(of: charSet) { + characterSet = .newlines + } else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) { + characterSet = .punctuationCharacters + } else { + return NSRange(location: position, length: 0) + } + + guard let start = textStorage.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: position), + let end = textStorage.findNextOccurrenceOfCharacter(in: characterSet.inverted, from: position) else { + return NSRange(location: position, length: 0) + } + + return NSRange(start: start, end: end) + } + + /// Given a position, find the range of the entire line that exists at that position. + internal func findLineBoundary(at position: Int) -> NSRange { + guard let linePosition = layoutManager.textLineForOffset(position) else { + return NSRange(location: position, length: 0) + } + return linePosition.range + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+SetText.swift b/Sources/CodeEditTextView/TextView/TextView+SetText.swift index 7581dbcd7..6ba66916a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+SetText.swift +++ b/Sources/CodeEditTextView/TextView/TextView+SetText.swift @@ -20,6 +20,10 @@ extension TextView { public func setTextStorage(_ textStorage: NSTextStorage) { self.textStorage = textStorage + if let storageDelegate = textStorage.delegate as? MultiStorageDelegate { + self.storageDelegate = storageDelegate + } + subviews.forEach { view in view.removeFromSuperview() } @@ -27,9 +31,16 @@ extension TextView { textStorage.addAttributes(typingAttributes, range: documentRange) layoutManager.textStorage = textStorage layoutManager.reset() + storageDelegate.addDelegate(layoutManager) selectionManager.textStorage = textStorage selectionManager.setSelectedRanges(selectionManager.textSelections.map { $0.range }) + NotificationCenter.default.post( + Notification( + name: TextSelectionManager.selectionChangedNotification, + object: selectionManager + ) + ) _undoManager?.clearStack() diff --git a/Sources/CodeEditTextView/TextView/TextView+Setup.swift b/Sources/CodeEditTextView/TextView/TextView+Setup.swift index c894cf04e..a17d39026 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Setup.swift @@ -28,9 +28,6 @@ extension TextView { } func setUpScrollListeners(scrollView: NSScrollView) { - NotificationCenter.default.removeObserver(self, name: NSScrollView.willStartLiveScrollNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: NSScrollView.didEndLiveScrollNotification, object: nil) - NotificationCenter.default.addObserver( self, selector: #selector(scrollViewWillStartScroll), @@ -44,6 +41,22 @@ extension TextView { name: NSScrollView.didEndLiveScrollNotification, object: scrollView ) + + NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.updatedViewport(self?.visibleRect ?? .zero) + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.updatedViewport(self?.visibleRect ?? .zero) + } } @objc func scrollViewWillStartScroll() { diff --git a/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift index a12f1d830..14d803fba 100644 --- a/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift +++ b/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift @@ -14,7 +14,7 @@ extension TextView { } override public var undoManager: UndoManager? { - _undoManager?.manager + _undoManager } @objc func undo(_ sender: AnyObject?) { diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 84a0a46d6..14ed3914e 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -33,7 +33,7 @@ import TextStory /// [`NSTextInputClient`](https://developer.apple.com/documentation/appkit/nstextinputclient) to work well with system /// text interactions such as inserting text and marked text. /// -public class TextView: NSView, NSTextContent { +open class TextView: NSView, NSTextContent { // MARK: - Statics /// The default typing attributes: @@ -58,7 +58,6 @@ public class TextView: NSView, NSTextContent { textStorage.string } set { - layoutManager.willReplaceCharactersInRange(range: documentRange, with: newValue) textStorage.setAttributedString(NSAttributedString(string: newValue, attributes: typingAttributes)) } } @@ -107,6 +106,16 @@ public class TextView: NSView, NSTextContent { } } + /// The amount of extra space to add when overscroll is enabled, as a percentage of the viewport height + public var overscrollAmount: CGFloat = 0.5 { + didSet { + if overscrollAmount < 0 { + overscrollAmount = 0 + } + updateFrameIfNeeded() + } + } + /// Whether or not the editor should wrap lines public var wrapLines: Bool { get { @@ -235,25 +244,39 @@ public class TextView: NSView, NSTextContent { /// layout system. Use methods like ``TextView/replaceCharacters(in:with:)-58mt7`` or /// ``TextView/insertText(_:)`` to modify content. package(set) public var textStorage: NSTextStorage! + /// The layout manager for the text view. package(set) public var layoutManager: TextLayoutManager! + /// The selection manager for the text view. package(set) public var selectionManager: TextSelectionManager! - /// Empasizse text ranges in the text view - public var emphasizeAPI: EmphasizeAPI? + /// Manages emphasized text ranges in the text view + public var emphasisManager: EmphasisManager? // MARK: - Private Properties var isFirstResponder: Bool = false + + /// When dragging to create a selection, these enable us to scroll the view as the user drags outside the view's + /// bounds. var mouseDragAnchor: CGPoint? var mouseDragTimer: Timer? + var cursorSelectionMode: CursorSelectionMode = .character + + /// When we receive a drag operation we add a temporary cursor view not managed by the selection manager. + /// This is the reference to that view, it is cleaned up when a drag ends. + var draggingCursorView: NSView? + var isDragging: Bool = false + + var isOptionPressed: Bool = false private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } internal(set) public var _undoManager: CEUndoManager? + @objc dynamic open var allowsUndo: Bool var scrollView: NSScrollView? { @@ -298,13 +321,18 @@ public class TextView: NSView, NSTextContent { super.init(frame: .zero) - self.emphasizeAPI = EmphasizeAPI(textView: self) - self.storageDelegate = MultiStorageDelegate() + self.emphasisManager = EmphasisManager(textView: self) + if let storageDelegate = textStorage.delegate as? MultiStorageDelegate { + self.storageDelegate = storageDelegate + } else { + self.storageDelegate = MultiStorageDelegate() + } wantsLayer = true postsFrameChangedNotifications = true postsBoundsChangedNotifications = true autoresizingMask = [.width, .height] + registerForDraggedTypes([.string, .fileContents, .html, .multipleTextSelection, .tabularText, .rtf]) self.typingAttributes = [ .font: font, @@ -316,16 +344,19 @@ public class TextView: NSView, NSTextContent { layoutManager = setUpLayoutManager(lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines) storageDelegate.addDelegate(layoutManager) + selectionManager = setUpSelectionManager() selectionManager.useSystemCursor = useSystemCursor + layoutManager.attachments.setUpSelectionListener(for: selectionManager) + _undoManager = CEUndoManager(textView: self) layoutManager.layoutLines() setUpDragGesture() } - required init?(coder: NSCoder) { + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -333,31 +364,6 @@ public class TextView: NSView, NSTextContent { NSRange(location: 0, length: textStorage.length) } - // MARK: - View Lifecycle - - override public func layout() { - layoutManager.layoutLines() - super.layout() - } - - override public func viewWillMove(toWindow newWindow: NSWindow?) { - super.viewWillMove(toWindow: newWindow) - layoutManager.layoutLines() - } - - override public func viewWillMove(toSuperview newSuperview: NSView?) { - guard let scrollView = enclosingScrollView else { - return - } - - setUpScrollListeners(scrollView: scrollView) - } - - override public func viewDidEndLiveResize() { - super.viewDidEndLiveResize() - updateFrameIfNeeded() - } - // MARK: - Hit test /// Returns the responding view for a given point. diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index c716d49b4..2af68d9e0 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -15,45 +15,7 @@ import TextStory /// - Grouping pasted text /// /// If needed, the automatic undo grouping can be overridden using the `beginGrouping()` and `endGrouping()` methods. -public class CEUndoManager { - /// An `UndoManager` subclass that forwards relevant actions to a `CEUndoManager`. - /// Allows for objects like `TextView` to use the `UndoManager` API - /// while CETV manages the undo/redo actions. - public class DelegatedUndoManager: UndoManager { - weak var parent: CEUndoManager? - - public override var isUndoing: Bool { parent?.isUndoing ?? false } - public override var isRedoing: Bool { parent?.isRedoing ?? false } - public override var canUndo: Bool { parent?.canUndo ?? false } - public override var canRedo: Bool { parent?.canRedo ?? false } - - public func registerMutation(_ mutation: TextMutation) { - parent?.registerMutation(mutation) - removeAllActions() - } - - public override func undo() { - parent?.undo() - } - - public override func redo() { - parent?.redo() - } - - public override func beginUndoGrouping() { - parent?.beginUndoGrouping() - } - - public override func endUndoGrouping() { - parent?.endUndoGrouping() - } - - public override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { - // no-op, but just in case to save resources: - removeAllActions() - } - } - +public class CEUndoManager: UndoManager { /// Represents a group of mutations that should be treated as one mutation when undoing/redoing. private struct UndoGroup { var mutations: [Mutation] @@ -65,16 +27,17 @@ public class CEUndoManager { var inverse: TextMutation } - public let manager: DelegatedUndoManager - private(set) public var isUndoing: Bool = false - private(set) public var isRedoing: Bool = false + private var _isUndoing: Bool = false + private var _isRedoing: Bool = false - public var canUndo: Bool { - !undoStack.isEmpty - } - public var canRedo: Bool { - !redoStack.isEmpty - } + override public var isUndoing: Bool { _isUndoing } + override public var isRedoing: Bool { _isRedoing } + + override public var undoCount: Int { undoStack.count } + override public var redoCount: Int { redoStack.count } + + override public var canUndo: Bool { !undoStack.isEmpty } + override public var canRedo: Bool { !redoStack.isEmpty } /// A stack of operations that can be undone. private var undoStack: [UndoGroup] = [] @@ -83,15 +46,17 @@ public class CEUndoManager { private weak var textView: TextView? private(set) public var isGrouping: Bool = false + + /// After ``endUndoGrouping`` is called, we'd expect the next mutation to be exclusive no matter what. This + /// flag facilitates that, and is set by ``endUndoGrouping`` + private var shouldBreakNextGroup: Bool = false + /// True when the manager is ignoring mutations. private var isDisabled: Bool = false // MARK: - Init - public init() { - self.manager = DelegatedUndoManager() - manager.parent = self - } + override public init() { } convenience init(textView: TextView) { self.init() @@ -101,37 +66,86 @@ public class CEUndoManager { // MARK: - Undo/Redo /// Performs an undo operation if there is one available. - public func undo() { - guard !isDisabled, let item = undoStack.popLast(), let textView else { + override public func undo() { + guard !isDisabled, let textView else { + return + } + + guard let item = undoStack.popLast() else { + NSSound.beep() return } - isUndoing = true - NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self.manager) + + _isUndoing = true + NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self) textView.textStorage.beginEditing() for mutation in item.mutations.reversed() { - textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string) + textView.replaceCharacters( + in: mutation.inverse.range, + with: mutation.inverse.string, + skipUpdateSelection: true + ) } textView.textStorage.endEditing() - NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager) + + updateSelectionsForMutations(mutations: item.mutations.map { $0.mutation }) + textView.scrollSelectionToVisible() + + NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self) redoStack.append(item) - isUndoing = false + _isUndoing = false } /// Performs a redo operation if there is one available. - public func redo() { - guard !isDisabled, let item = redoStack.popLast(), let textView else { + override public func redo() { + guard !isDisabled, let textView else { + return + } + + guard let item = redoStack.popLast() else { + NSSound.beep() return } - isRedoing = true - NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self.manager) + + _isRedoing = true + NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self) + textView.selectionManager.removeCursors() textView.textStorage.beginEditing() for mutation in item.mutations { - textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string) + textView.replaceCharacters( + in: mutation.mutation.range, + with: mutation.mutation.string, + skipUpdateSelection: true + ) } textView.textStorage.endEditing() - NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager) + + updateSelectionsForMutations(mutations: item.mutations.map { $0.inverse }) + textView.scrollSelectionToVisible() + + NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self) undoStack.append(item) - isRedoing = false + _isRedoing = false + } + + /// We often undo/redo a group of mutations that contain updated ranges that are next to each other but for a user + /// should be one continuous range. This merges those ranges into a set of disjoint ranges before updating the + /// selection manager. + private func updateSelectionsForMutations(mutations: [TextMutation]) { + if mutations.reduce(0, { $0 + $1.range.length }) == 0 { + if let minimumMutation = mutations.min(by: { $0.range.location < $1.range.location }) { + // If the mutations are only deleting text (no replacement), we just place the cursor at the last range, + // since all the ranges are the same but the other method will return no ranges (empty range). + textView?.selectionManager.setSelectedRange( + NSRange(location: minimumMutation.range.location, length: 0) + ) + } + } else { + let mergedRanges = mutations.reduce(into: IndexSet(), { set, mutation in + set.insert(range: mutation.range) + }) + textView?.selectionManager.setSelectedRanges(mergedRanges.rangeView.map { NSRange($0) }) + } } /// Clears the undo/redo stacks. @@ -142,11 +156,17 @@ public class CEUndoManager { // MARK: - Mutations + public override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { + // no-op, but just in case to save resources: + removeAllActions() + } + /// Registers a mutation into the undo stack. /// /// Calling this method while the manager is in an undo/redo operation will result in a no-op. /// - Parameter mutation: The mutation to register for undo/redo public func registerMutation(_ mutation: TextMutation) { + removeAllActions() guard let textView, let textStorage = textView.textStorage, !isUndoing, @@ -154,39 +174,38 @@ public class CEUndoManager { return } let newMutation = Mutation(mutation: mutation, inverse: textStorage.inverseMutation(for: mutation)) - if !undoStack.isEmpty, let lastMutation = undoStack.last?.mutations.last { - if isGrouping || shouldContinueGroup(newMutation, lastMutation: lastMutation) { - undoStack[undoStack.count - 1].mutations.append(newMutation) - } else { - undoStack.append(UndoGroup(mutations: [newMutation])) - } + // We can continue a group if: + // - A group exists + // - We're not direct to break the current group + // - We're forced grouping OR we automagically detect we can group. + if !undoStack.isEmpty, + let lastMutation = undoStack.last?.mutations.last, + !shouldBreakNextGroup, + isGrouping || shouldContinueGroup(newMutation, lastMutation: lastMutation) { + undoStack[undoStack.count - 1].mutations.append(newMutation) } else { - undoStack.append( - UndoGroup(mutations: [newMutation]) - ) + undoStack.append(UndoGroup(mutations: [newMutation])) + shouldBreakNextGroup = false } - redoStack.removeAll() } // MARK: - Grouping /// Groups all incoming mutations. - public func beginUndoGrouping() { - guard !isGrouping else { - assertionFailure("UndoManager already in a group. Call `beginUndoGrouping` before this can be called.") - return - } + override public func beginUndoGrouping() { + guard !isGrouping else { return } isGrouping = true + // This is a new undo group, break for it. + shouldBreakNextGroup = true } /// Stops grouping all incoming mutations. - public func endUndoGrouping() { - guard isGrouping else { - assertionFailure("UndoManager not in a group. Call `endUndoGrouping` before this can be called.") - return - } + override public func endUndoGrouping() { + guard isGrouping else { return } isGrouping = false + // We just ended a group, do not allow the next mutation to be added to the group we just made. + shouldBreakNextGroup = true } /// Determines whether or not two mutations should be grouped. diff --git a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift index c969c573c..3b85c6aeb 100644 --- a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift +++ b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift @@ -24,29 +24,39 @@ public class ViewReuseQueue { /// If there was no view dequeued for the given key, the returned view will either be a view queued for reuse or a /// new view object. /// - /// - Parameter key: The key for the view to find. + /// - Parameters: + /// - key: The key for the view to find. + /// - createView: A callback that is called to create a new instance of the queued view types. /// - Returns: A view for the given key. - public func getOrCreateView(forKey key: Key) -> View { + public func getOrCreateView(forKey key: Key, createView: () -> View) -> View { let view: View if let usedView = usedViews[key] { view = usedView } else { - view = queuedViews.popFirst() ?? View() + view = queuedViews.popFirst() ?? createView() view.prepareForReuse() + view.isHidden = false usedViews[key] = view } return view } + public func getView(forKey key: Key) -> View? { + usedViews[key] + } + /// Removes a view for the given key and enqueues it for reuse. /// - Parameter key: The key for the view to reuse. public func enqueueView(forKey key: Key) { guard let view = usedViews[key] else { return } - if queuedViews.count < usedViews.count / 4 { + if queuedViews.count < usedViews.count { queuedViews.append(view) + view.frame = .zero + view.isHidden = true + } else { + view.removeFromSuperviewWithoutNeedingDisplay() } usedViews.removeValue(forKey: key) - view.removeFromSuperviewWithoutNeedingDisplay() } /// Enqueues all views not in the given set. diff --git a/Tests/CodeEditTextViewTests/AccessibilityTests.swift b/Tests/CodeEditTextViewTests/AccessibilityTests.swift new file mode 100644 index 000000000..ed526054b --- /dev/null +++ b/Tests/CodeEditTextViewTests/AccessibilityTests.swift @@ -0,0 +1,299 @@ +// +// AccessibilityTests.swift +// CodeEditTextView +// +// Created by Khan Winter on 7/17/25. +// + +import Testing +import AppKit +@testable import CodeEditTextView + +@MainActor +@Suite +struct AccessibilityTests { + let textView: TextView + let sampleText = "Line 1\nLine 2\nLine 3" + + init() { + textView = TextView(string: sampleText) + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textView.updateFrameIfNeeded() + } + + // MARK: - Basic Accessibility Properties + + @Test + func isAccessibilityElement() { + #expect(textView.isAccessibilityElement()) + } + + @Test + func isAccessibilityEnabled() { + #expect(textView.isAccessibilityEnabled()) + } + + @Test + func accessibilityLabel() { + #expect(textView.accessibilityLabel() == "Text Editor") + } + + @Test + func accessibilityRole() { + #expect(textView.accessibilityRole() == .textArea) + } + + @Test + func accessibilityValue() { + #expect(textView.accessibilityValue() as? String == sampleText) + } + + @Test + func setAccessibilityValue() { + let newValue = "New content" + textView.setAccessibilityValue(newValue) + #expect(textView.string == newValue) + } + + @Test + func setAccessibilityValueInvalidType() { + let originalString = textView.string + textView.setAccessibilityValue(42) + #expect(textView.string == originalString) + } + + // MARK: - Character and String Access + + @Test + func accessibilityNumberOfCharacters() { + #expect(textView.accessibilityNumberOfCharacters() == sampleText.count) + } + + @Test + func accessibilityStringForRange() { + let range = NSRange(location: 0, length: 6) + let result = textView.accessibilityString(for: range) + #expect(result == "Line 1") + } + + @Test + func accessibilityStringForInvalidRange() { + let range = NSRange(location: 100, length: 5) + let result = textView.accessibilityString(for: range) + #expect(result == nil) + } + + @Test + func accessibilityRangeForCharacterIndex() { + let range = textView.accessibilityRange(for: 0) + #expect(range.location == 0) + #expect(range.length == 1) + } + + @Test + func accessibilityRangeForInvalidIndex() { + let range = textView.accessibilityRange(for: 1000) + #expect(range == .notFound) + } + + // MARK: - Selection Tests + + @Test + func accessibilitySelectedTextNoSelections() { + textView.selectionManager.setSelectedRanges([]) + #expect(textView.accessibilitySelectedText() == nil) + } + + @Test + func accessibilitySelectedTextEmpty() { + textView.selectionManager.setSelectedRange(.zero) + #expect(textView.accessibilitySelectedText() == "") + } + + @Test + func accessibilitySelectedText() { + let range = NSRange(location: 0, length: 6) + textView.selectionManager.setSelectedRange(range) + #expect(textView.accessibilitySelectedText() == "Line 1") + } + + @Test + func accessibilitySelectedTextRange() { + let range = NSRange(location: 2, length: 4) + textView.selectionManager.setSelectedRange(range) + let selectedRange = textView.accessibilitySelectedTextRange() + #expect(selectedRange.location == 2) + #expect(selectedRange.length == 4) + } + + @Test + func accessibilitySelectedTextRangeEmpty() { + textView.selectionManager.setSelectedRange(.zero) + let selectedRange = textView.accessibilitySelectedTextRange() + #expect(selectedRange == .zero) + } + + @Test + func setAccessibilitySelectedTextRange() { + let range = NSRange(location: 7, length: 6) + textView.setAccessibilitySelectedTextRange(range) + #expect(textView.accessibilitySelectedTextRange() == range) + } + + @Test + func accessibilitySelectedTextRanges() { + let ranges = [ + NSRange(location: 0, length: 4), + NSRange(location: 7, length: 6) + ] + textView.selectionManager.setSelectedRanges(ranges) + let selectedRanges = textView.accessibilitySelectedTextRanges()?.compactMap { $0 as? NSRange } + #expect(selectedRanges?.count == 2) + #expect(selectedRanges?.contains(ranges[0]) == true) + #expect(selectedRanges?.contains(ranges[1]) == true) + } + + @Test + func setAccessibilitySelectedTextRanges() { + let ranges = [ + NSRange(location: 0, length: 4) as NSValue, + NSRange(location: 7, length: 6) as NSValue + ] + textView.setAccessibilitySelectedTextRanges(ranges) + let selectedRanges = textView.accessibilitySelectedTextRanges() + #expect(selectedRanges?.count == 2) + } + + @Test + func setAccessibilitySelectedTextRangesNil() { + textView.setAccessibilitySelectedTextRanges(nil) + let selectedRanges = textView.accessibilitySelectedTextRanges() + #expect(selectedRanges?.isEmpty == true) + } + + // MARK: - Line Navigation Tests + + @Test + func accessibilityLineForIndex() { + let lineIndex = textView.accessibilityLine(for: 0) + #expect(lineIndex == 0) + } + + @Test + func accessibilityLineForIndexSecondLine() { + let lineIndex = textView.accessibilityLine(for: 7) + #expect(lineIndex == 1) + } + + @Test + func accessibilityLineForEndOfDocument() { + let lineIndex = textView.accessibilityLine(for: textView.documentRange.max) + #expect(lineIndex == 2) + } + + @Test + func accessibilityLineForInvalidIndex() { + let lineIndex = textView.accessibilityLine(for: 1000) + #expect(lineIndex == -1) + } + + @Test + func accessibilityRangeForLine() { + let range = textView.accessibilityRange(forLine: 0) + #expect(range.location == 0) + #expect(range.length == 7) + } + + @Test + func accessibilityRangeForLineSecondLine() { + let range = textView.accessibilityRange(forLine: 1) + #expect(range.location == 7) + #expect(range.length == 7) + } + + @Test + func accessibilityRangeForInvalidLine() { + let range = textView.accessibilityRange(forLine: 100) + #expect(range == .zero) + } + + @Test + func accessibilityRangeForNegativeLine() { + let range = textView.accessibilityRange(forLine: -1) + #expect(range == .zero) + } + + @Test + func accessibilityInsertionPointLineNumber() { + textView.selectionManager.setSelectedRange(NSRange(location: 7, length: 0)) + let lineNumber = textView.accessibilityInsertionPointLineNumber() + #expect(lineNumber == 1) + } + + @Test + func accessibilityInsertionPointLineNumberEmptySelection() { + textView.selectionManager.setSelectedRange(.zero) + let lineNumber = textView.accessibilityInsertionPointLineNumber() + #expect(lineNumber == 0) + } + + @Test + func accessibilityInsertionPointLineNumberNoSelection() { + textView.selectionManager.setSelectedRanges([]) + let lineNumber = textView.accessibilityInsertionPointLineNumber() + #expect(lineNumber == -1) + } + + // MARK: - Visible Range Tests + + @Test + func accessibilityVisibleCharacterRange() { + let visibleRange = textView.accessibilityVisibleCharacterRange() + #expect(visibleRange != .notFound) + } + + @Test + func accessibilityVisibleCharacterRangeNoVisibleText() { + let emptyTextView = TextView(string: "") + let visibleRange = emptyTextView.accessibilityVisibleCharacterRange() + #expect(visibleRange == .zero) + } + + // MARK: - Point and Frame Tests + + @Test + func accessibilityRangeForPoint() { + let point = NSPoint(x: 10, y: 10) + let range = textView.accessibilityRange(for: point) + #expect(range.length == 0) + } + + @Test + func accessibilityRangeForInvalidPoint() { + let point = NSPoint(x: -100, y: -100) + let range = textView.accessibilityRange(for: point) + #expect(range == .zero) + } + + @Test + func accessibilityFrameForRange() { + let range = NSRange(location: 0, length: 6) + let frame = textView.accessibilityFrame(for: range) + #expect(frame.size.width > 0) + #expect(frame.size.height > 0) + } + + @Test + func accessibilityFrameForEmptyRange() { + let range = NSRange(location: 0, length: 0) + let frame = textView.accessibilityFrame(for: range) + #expect(frame.size.width >= 0) + #expect(frame.size.height >= 0) + } + + @Test + func isAccessibilityFocusedWhenNotFirstResponder() { + textView.window?.makeFirstResponder(nil) + #expect(!textView.isAccessibilityFocused()) + } +} diff --git a/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift new file mode 100644 index 000000000..4cdf8468b --- /dev/null +++ b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift @@ -0,0 +1,37 @@ +import Testing +import Foundation +@testable import CodeEditTextView + +@Suite() +struct EmphasisManagerTests { + @Test() + @MainActor + func testFlashEmphasisLayersNotLeaked() { + // Ensure layers are not leaked when switching from flash emphasis to any other emphasis type. + let textView = TextView(string: "Lorem Ipsum") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 100) + textView.layoutManager.layoutLines(in: CGRect(origin: .zero, size: CGSize(width: 1000, height: 100))) + textView.emphasisManager?.addEmphasis( + Emphasis(range: NSRange(location: 0, length: 5), style: .standard, flash: true), + for: "e" + ) + + // Text layer and emphasis layer + #expect(textView.layer?.sublayers?.count == 2) + #expect(textView.emphasisManager?.getEmphases(for: "e").count == 1) + + textView.emphasisManager?.addEmphases( + [Emphasis(range: NSRange(location: 0, length: 5), style: .underline(color: .red), flash: true)], + for: "e" + ) + + #expect(textView.layer?.sublayers?.count == 4) + #expect(textView.emphasisManager?.getEmphases(for: "e").count == 2) + + textView.emphasisManager?.removeAllEmphases() + + // No emphasis layers remain + #expect(textView.layer?.sublayers?.count == nil) + #expect(textView.emphasisManager?.getEmphases(for: "e").count == 0) + } +} diff --git a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift new file mode 100644 index 000000000..07f222fb6 --- /dev/null +++ b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift @@ -0,0 +1,112 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +class MockRenderDelegate: TextLayoutManagerRenderDelegate { + var prepareForDisplay: (( + _ textLine: TextLine, + _ displayData: TextLine.DisplayData, + _ range: NSRange, + _ stringRef: NSTextStorage, + _ markedRanges: MarkedRanges? + ) -> Void)? + + var estimatedLineHeightOverride: (() -> CGFloat)? + + func prepareForDisplay( // swiftlint:disable:this function_parameter_count + textLine: TextLine, + displayData: TextLine.DisplayData, + range: NSRange, + stringRef: NSTextStorage, + markedRanges: MarkedRanges?, + attachments: [AnyTextAttachment] + ) { + prepareForDisplay?( + textLine, + displayData, + range, + stringRef, + markedRanges + ) ?? textLine.prepareForDisplay( + displayData: displayData, + range: range, + stringRef: stringRef, + markedRanges: markedRanges, + attachments: [] + ) + } + + func estimatedLineHeight() -> CGFloat? { + estimatedLineHeightOverride?() + } +} + +@Suite +@MainActor +struct OverridingLayoutManagerRenderingTests { + let mockDelegate: MockRenderDelegate + let textView: TextView + let textStorage: NSTextStorage + let layoutManager: TextLayoutManager + + init() throws { + textView = TextView(string: "A\nB\nC\nD") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textStorage = textView.textStorage + layoutManager = try #require(textView.layoutManager) + mockDelegate = MockRenderDelegate() + layoutManager.renderDelegate = mockDelegate + } + + @Test + func overriddenLineHeight() { + mockDelegate.prepareForDisplay = { textLine, displayData, range, stringRef, markedRanges in + textLine.prepareForDisplay( + displayData: displayData, + range: range, + stringRef: stringRef, + markedRanges: markedRanges, + attachments: [] + ) + // Update all text fragments to be height = 2.0 + textLine.lineFragments.forEach { fragmentPosition in + let idealHeight: CGFloat = 2.0 + textLine.lineFragments.update( + atOffset: fragmentPosition.index, + delta: 0, + deltaHeight: -(fragmentPosition.height - idealHeight) + ) + fragmentPosition.data.height = 2.0 + fragmentPosition.data.scaledHeight = 2.0 + } + } + + layoutManager.invalidateLayoutForRect(NSRect(x: 0, y: 0, width: 1000, height: 1000)) + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + // 4 lines, each 2px tall + #expect(layoutManager.lineStorage.height == 8.0) + + // Edit some text + + textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "0\n1\r\n2\r") + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + #expect(layoutManager.lineCount == 7) + #expect(layoutManager.lineStorage.height == 14.0) + layoutManager.lineStorage.validateInternalState() + } + + @Test + func overriddenEstimatedLineHeight() { + // The layout manager should use the estimation from the render delegate, not the font size. + mockDelegate.estimatedLineHeightOverride = { + 1.0 + } + + layoutManager.renderDelegate = mockDelegate + + #expect(layoutManager.estimateLineHeight() == 1.0) + #expect(layoutManager.estimatedHeight() == 4.0) // 4 lines, each 1 high + } +} diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift new file mode 100644 index 000000000..1841cc5ed --- /dev/null +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -0,0 +1,121 @@ +// +// TextLayoutManagerAttachmentsTests.swift +// CodeEditTextView +// +// Created by Khan Winter on 5/5/25. +// + +import Testing +import AppKit +@testable import CodeEditTextView + +@Suite +@MainActor +struct TextLayoutManagerAttachmentsTests { + let textView: TextView + let textStorage: NSTextStorage + let layoutManager: TextLayoutManager + + init() throws { + textView = TextView(string: "12\n45\n78\n01\n") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textStorage = textView.textStorage + layoutManager = try #require(textView.layoutManager) + } + + @Test + func addAndGetAttachments() throws { + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8)) + #expect(layoutManager.attachments.getAttachmentsOverlapping(textView.documentRange).count == 1) + #expect(layoutManager.attachments.getAttachmentsOverlapping(NSRange(start: 0, end: 3)).count == 1) + #expect(layoutManager.attachments.getAttachmentsStartingIn(NSRange(start: 0, end: 3)).count == 1) + } + + // MARK: - Determine Visible Line Tests + + @Test + func determineVisibleLinesMovesForwards() throws { + // From middle of the first line, to middle of the third line + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8)) + + // Start with the first line, should extend to the third line + let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 0)) // zero-indexed + let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) + + #expect(newPosition.indexRange == 0...2) + #expect(newPosition.position.range == NSRange(start: 0, end: 9)) // Lines one -> three + } + + @Test + func determineVisibleLinesMovesBackwards() throws { + // From middle of the first line, to middle of the third line + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8)) + + // Start with the third line, should extend back to the first line + let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed + let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) + + #expect(newPosition.indexRange == 0...2) + #expect(newPosition.position.range == NSRange(start: 0, end: 9)) // Lines one -> three + } + + @Test + func determineVisibleLinesMergesMultipleAttachments() throws { + // Two attachments, meeting at the third line. `determineVisiblePosition` should merge all four lines. + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7)) + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 7, end: 11)) + + let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed + let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) + + #expect(newPosition.indexRange == 0...3) + #expect(newPosition.position.range == NSRange(start: 0, end: 12)) // Lines one -> four + } + + @Test + func determineVisibleLinesMergesOverlappingAttachments() throws { + // Two attachments, overlapping at the third line. `determineVisiblePosition` should merge all four lines. + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7)) + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 5, end: 11)) + + let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed + let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition)) + + #expect(newPosition.indexRange == 0...3) + #expect(newPosition.position.range == NSRange(start: 0, end: 12)) // Lines one -> four + } + + // MARK: - Iterator Tests + + @Test + func iterateWithAttachments() { + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 1, end: 2)) + + let lines = layoutManager.linesStartingAt(0, until: 1000) + + // Line "5" is from the trailing newline. That shows up as an empty line in the view. + #expect(lines.map { $0.index } == [0, 1, 2, 3, 4]) + } + + @Test + func iterateWithMultilineAttachments() { + // Two attachments, meeting at the third line. + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7)) + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 7, end: 11)) + + let lines = layoutManager.linesStartingAt(0, until: 1000) + + // Line "5" is from the trailing newline. That shows up as an empty line in the view. + #expect(lines.map { $0.index } == [0, 4]) + } + + @Test + func addingAttachmentThatMeetsEndOfLineMergesNextLine() throws { + let height = try #require(layoutManager.textLineForOffset(0)).height + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 0, end: 3)) + + // With bug: the line for offset 3 would be the 2nd line (index 1). They should be merged + #expect(layoutManager.textLineForOffset(0)?.index == 0) + #expect(layoutManager.textLineForOffset(3)?.index == 0) + } +} diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift new file mode 100644 index 000000000..f40c1b878 --- /dev/null +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -0,0 +1,272 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +extension TextLineStorage { + /// Validate that the internal tree is intact and correct. + /// + /// Ensures that: + /// - All lines can be queried by their index starting from `0`. + /// - All lines can be found by iterating `y` positions. + func validateInternalState() { + func validateLines(_ lines: [TextLineStorage.TextLinePosition]) { + var _lastLine: TextLineStorage.TextLinePosition? + for line in lines { + guard let lastLine = _lastLine else { + #expect(line.index == 0) + _lastLine = line + return + } + + #expect(line.index == lastLine.index + 1) + #expect(line.yPos >= lastLine.yPos + lastLine.height) + #expect(line.range.location == lastLine.range.max + 1) + _lastLine = line + } + } + + let linesUsingIndex = (0.. TextLineStorage { @@ -20,16 +25,16 @@ final class TextLayoutLineStorageTests: XCTestCase { return tree } + struct ChildData { + let length: Int + let count: Int + let height: CGFloat + } + /// Recursively checks that the given tree has the correct metadata everywhere. /// - Parameter tree: The tree to check. - fileprivate func assertTreeMetadataCorrect(_ tree: TextLineStorage) throws { - struct ChildData { - let length: Int - let count: Int - let height: CGFloat - } - - func checkChildren(_ node: TextLineStorage.Node?) -> ChildData { + fileprivate func assertTreeMetadataCorrect(_ tree: TextLineStorage) throws { + func checkChildren(_ node: TextLineStorage.Node?) -> ChildData { guard let node else { return ChildData(length: 0, count: 0, height: 0.0) } let leftSubtreeData = checkChildren(node.left) let rightSubtreeData = checkChildren(node.right) @@ -89,7 +94,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Single Element tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) - tree.update(atIndex: 0, delta: 20, deltaHeight: 5.0) + tree.update(atOffset: 0, delta: 20, deltaHeight: 5.0) XCTAssertEqual(tree.length, 21, "Tree length incorrect") XCTAssertEqual(tree.count, 1, "Tree count incorrect") XCTAssertEqual(tree.height, 6, "Tree height incorrect") @@ -98,7 +103,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Update First tree = createBalancedTree() - tree.update(atIndex: 0, delta: 12, deltaHeight: -0.5) + tree.update(atOffset: 0, delta: 12, deltaHeight: -0.5) XCTAssertEqual(tree.height, 14.5, "Tree height incorrect") XCTAssertEqual(tree.count, 15, "Tree count changed") XCTAssertEqual(tree.length, 132, "Tree length incorrect") @@ -107,7 +112,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Update Last tree = createBalancedTree() - tree.update(atIndex: tree.length - 1, delta: -14, deltaHeight: 1.75) + tree.update(atOffset: tree.length - 1, delta: -14, deltaHeight: 1.75) XCTAssertEqual(tree.height, 16.75, "Tree height incorrect") XCTAssertEqual(tree.count, 15, "Tree count changed") XCTAssertEqual(tree.length, 106, "Tree length incorrect") @@ -116,7 +121,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Update middle tree = createBalancedTree() - tree.update(atIndex: 45, delta: -9, deltaHeight: 1.0) + tree.update(atOffset: 45, delta: -9, deltaHeight: 1.0) XCTAssertEqual(tree.height, 16.0, "Tree height incorrect") XCTAssertEqual(tree.count, 15, "Tree count changed") XCTAssertEqual(tree.length, 111, "Tree length incorrect") @@ -131,7 +136,7 @@ final class TextLayoutLineStorageTests: XCTestCase { let originalHeight = tree.height let originalCount = tree.count let originalLength = tree.length - tree.update(atIndex: Int.random(in: 0.. + typealias Node = TextLineStorage.Node + // Test that when transplanting a node with no left nodes, with a node with left nodes, that + // the resulting tree has valid 'left_' metadata + // 1 + // / \ + // 7 2 + // / + // 3 ← this will be moved, this test ensures 4 retains it's left subtree count + // \ + // 4 + // | | + // 5 6 + + let node5 = Node( + length: 5, + data: UUID(), + leftSubtreeOffset: 0, + leftSubtreeHeight: 0, + leftSubtreeCount: 0, + height: 1, + left: nil, + right: nil, + parent: nil, + color: .black + ) + + let node6 = Node( + length: 6, + data: UUID(), + leftSubtreeOffset: 0, + leftSubtreeHeight: 0, + leftSubtreeCount: 0, + height: 1, + left: nil, + right: nil, + parent: nil, + color: .black + ) + + let node4 = Node( + length: 4, + data: UUID(), + leftSubtreeOffset: 5, + leftSubtreeHeight: 1, + leftSubtreeCount: 1, // node5 is on the left + height: 1, + left: node5, + right: node6, + parent: nil, + color: .black + ) + node5.parent = node4 + node6.parent = node4 + + let node3 = Node( + length: 3, + data: UUID(), + leftSubtreeOffset: 0, + leftSubtreeHeight: 0, + leftSubtreeCount: 0, + height: 1, + left: nil, + right: node4, + parent: nil, + color: .black + ) + node4.parent = node3 + + let node2 = Node( + length: 2, + data: UUID(), + leftSubtreeOffset: 18, + leftSubtreeHeight: 4, + leftSubtreeCount: 4, // node3 is on the left + height: 1, + left: node3, + right: nil, + parent: nil, + color: .black + ) + node3.parent = node2 + + let node7 = Node(length: 7, data: UUID(), height: 1) + + let node1 = Node( + length: 1, + data: UUID(), + leftSubtreeOffset: 7, + leftSubtreeHeight: 1, + leftSubtreeCount: 1, + height: 1, + left: node7, + right: node2, + parent: nil, + color: .black + ) + node2.parent = node1 + + let storage = Storage(root: node1, count: 7, length: 28, height: 7) + + storage.delete(lineAt: 7) // Delete the root + + try assertTreeMetadataCorrect(storage) + } } diff --git a/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift b/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift index 38ea3983d..ecfa6ab84 100644 --- a/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift +++ b/Tests/CodeEditTextViewTests/TextSelectionManagerTests.swift @@ -217,4 +217,10 @@ final class TextSelectionManagerTests: XCTestCase { ) } } + + func test_selectionEndOfDocumentHasXPos() { + let selectionManager = selectionManager("1\n2\n3\n") + selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) // Beyond text.length, end of doc + XCTAssertNotNil(selectionManager.textSelections.first?.suggestedXPos) + } } diff --git a/Tests/CodeEditTextViewTests/TextViewTests.swift b/Tests/CodeEditTextViewTests/TextViewTests.swift new file mode 100644 index 000000000..7b6ba44bb --- /dev/null +++ b/Tests/CodeEditTextViewTests/TextViewTests.swift @@ -0,0 +1,81 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +@Suite +@MainActor +struct TextViewTests { + class MockDelegate: TextViewDelegate { + var shouldReplaceContents: ((_ textView: TextView, _ range: NSRange, _ string: String) -> Bool)? + + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { + shouldReplaceContents?(textView, range, string) ?? true + } + } + + let textView: TextView + let delegate: MockDelegate + + init() { + textView = TextView(string: "Lorem Ipsum") + delegate = MockDelegate() + textView.delegate = delegate + } + + @Test + func delegateChangesText() { + var hasReplaced = false + delegate.shouldReplaceContents = { textView, _, _ -> Bool in + if !hasReplaced { + hasReplaced.toggle() + textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: " World ") + } + + return true + } + + textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "Hello") + + #expect(textView.string == "Hello World Lorem Ipsum") + // available in test module + textView.layoutManager.lineStorage.validateInternalState() + } + + @Test + func sharedTextStorage() { + let storage = NSTextStorage(string: "Hello world") + + let textView1 = TextView(string: "") + textView1.frame = NSRect(x: 0, y: 0, width: 100, height: 100) + textView1.layoutSubtreeIfNeeded() + textView1.setTextStorage(storage) + + let textView2 = TextView(string: "") + textView2.frame = NSRect(x: 0, y: 0, width: 100, height: 100) + textView2.layoutSubtreeIfNeeded() + textView2.setTextStorage(storage) + + // Expect both text views to receive edited events from the storage + #expect(textView1.layoutManager.lineCount == 1) + #expect(textView2.layoutManager.lineCount == 1) + + storage.replaceCharacters(in: NSRange(location: 11, length: 0), with: "\nMore Lines\n") + + #expect(textView1.layoutManager.lineCount == 3) + #expect(textView2.layoutManager.lineCount == 3) + } + + @Test("Custom UndoManager class receives events") + func customUndoManagerReceivesEvents() { + let textView = TextView(string: "") + + textView.replaceCharacters(in: .zero, with: "Hello World") + textView.undo(nil) + + #expect(textView.string == "") + + textView.redo(nil) + + #expect(textView.string == "Hello World") + } +} diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index 07954a7cf..92826c365 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -1,18 +1,42 @@ import XCTest @testable import CodeEditTextView -// swiftlint:disable all +final class DemoTextAttachment: TextAttachment { + var width: CGFloat + var isSelected: Bool = false + + init(width: CGFloat = 100) { + self.width = width + } + + func draw(in context: CGContext, rect: NSRect) { + context.saveGState() + context.setFillColor(NSColor.red.cgColor) + context.fill(rect) + context.restoreGState() + } +} class TypesetterTests: XCTestCase { - let limitedLineWidthDisplayData = TextLine.DisplayData(maxWidth: 150, lineHeightMultiplier: 1.0, estimatedLineHeight: 20.0) - let unlimitedLineWidthDisplayData = TextLine.DisplayData(maxWidth: .infinity, lineHeightMultiplier: 1.0, estimatedLineHeight: 20.0) + // NOTE: makes chars that are ~6.18 pts wide + let attributes: [NSAttributedString.Key: Any] = [.font: NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)] + var typesetter: Typesetter! + + override func setUp() { + typesetter = Typesetter() + continueAfterFailure = false + } func test_LineFeedBreak() { - let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\n"), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .word, + documentRange: NSRange(location: 0, length: 9), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .word + ), markedRanges: nil ) @@ -20,8 +44,13 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "testline\n"), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .character, + documentRange: NSRange(location: 0, length: 9), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), markedRanges: nil ) @@ -29,11 +58,15 @@ class TypesetterTests: XCTestCase { } func test_carriageReturnBreak() { - let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\r"), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .word, + documentRange: NSRange(location: 0, length: 9), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .word + ), markedRanges: nil ) @@ -41,8 +74,13 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "testline\r"), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .character, + documentRange: NSRange(location: 0, length: 9), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), markedRanges: nil ) @@ -50,11 +88,15 @@ class TypesetterTests: XCTestCase { } func test_carriageReturnLineFeedBreak() { - let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\r\n"), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .word, + documentRange: NSRange(location: 0, length: 10), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .word + ), markedRanges: nil ) @@ -62,13 +104,184 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "testline\r\n"), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .character, + documentRange: NSRange(location: 0, length: 10), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), markedRanges: nil ) XCTAssertEqual(typesetter.lineFragments.count, 1, "Typesetter typeset incorrect number of lines.") } -} -// swiftlint:enable all + func test_wrapLinesReturnsValidFragmentRanges() throws { + // Ensure that when wrapping, each wrapped line fragment has correct ranges. + typesetter.typeset( + NSAttributedString(string: String(repeating: "A", count: 1000), attributes: attributes), + documentRange: NSRange(location: 0, length: 1000), + displayData: TextLine.DisplayData( + maxWidth: 150, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), + markedRanges: nil, + attachments: [] + ) + + let firstFragment = try XCTUnwrap(typesetter.lineFragments.first) + + for fragment in typesetter.lineFragments { + // The end of the fragment shouldn't extend beyond the valid document range + XCTAssertLessThanOrEqual(fragment.range.max, 1000) + // Because we're breaking on characters, and filling each line with the same char + // Each fragment should be as long or shorter than the first fragment. + XCTAssertLessThanOrEqual(fragment.range.length, firstFragment.range.length) + } + } + + // MARK: - Attachments + + func test_layoutSingleFragmentWithAttachment() throws { + let attachment = DemoTextAttachment() + typesetter.typeset( + NSAttributedString(string: "ABC"), + documentRange: NSRange(location: 0, length: 3), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), + markedRanges: nil, + attachments: [AnyTextAttachment(range: NSRange(location: 1, length: 1), attachment: attachment)] + ) + + XCTAssertEqual(typesetter.lineFragments.count, 1) + let fragment = try XCTUnwrap(typesetter.lineFragments.first?.data) + XCTAssertEqual(fragment.contents.count, 3) + XCTAssertTrue(fragment.contents[0].isText) + XCTAssertFalse(fragment.contents[1].isText) + XCTAssertTrue(fragment.contents[2].isText) + XCTAssertEqual( + fragment.contents[1], + .init( + data: .attachment(attachment: .init(range: NSRange(location: 1, length: 1), attachment: attachment)), + width: attachment.width + ) + ) + } + + func test_layoutSingleFragmentEntirelyAttachment() throws { + let attachment = DemoTextAttachment() + typesetter.typeset( + NSAttributedString(string: "ABC"), + documentRange: NSRange(location: 0, length: 3), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), + markedRanges: nil, + attachments: [AnyTextAttachment(range: NSRange(location: 0, length: 3), attachment: attachment)] + ) + + XCTAssertEqual(typesetter.lineFragments.count, 1) + let fragment = try XCTUnwrap(typesetter.lineFragments.first?.data) + XCTAssertEqual(fragment.contents.count, 1) + XCTAssertFalse(fragment.contents[0].isText) + XCTAssertEqual( + fragment.contents[0], + .init( + data: .attachment(attachment: .init(range: NSRange(location: 0, length: 3), attachment: attachment)), + width: attachment.width + ) + ) + } + + func test_wrapLinesWithAttachment() throws { + let attachment = DemoTextAttachment(width: 130) + + // Total should be slightly > 160px, breaking off 2 and 3 + typesetter.typeset( + NSAttributedString(string: "ABC123", attributes: attributes), + documentRange: NSRange(location: 0, length: 6), + displayData: TextLine.DisplayData( + maxWidth: 150, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), + markedRanges: nil, + attachments: [.init(range: NSRange(location: 1, length: 1), attachment: attachment)] + ) + + XCTAssertEqual(typesetter.lineFragments.count, 2) + + var fragment = try XCTUnwrap(typesetter.lineFragments.first?.data) + XCTAssertEqual(fragment.contents.count, 3) // First fragment includes the attachment and characters after + XCTAssertTrue(fragment.contents[0].isText) + XCTAssertFalse(fragment.contents[1].isText) + XCTAssertTrue(fragment.contents[2].isText) + + fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 1)?.data) + XCTAssertEqual(fragment.contents.count, 1) // Second fragment is only text + XCTAssertTrue(fragment.contents[0].isText) + } + + func test_wrapLinesWithWideAttachment() throws { + // Attachment takes up more than the available room. + // Expected result: attachment is on it's own line fragment with no other text. + let attachment = DemoTextAttachment(width: 150) + + typesetter.typeset( + NSAttributedString(string: "ABC123", attributes: attributes), + documentRange: NSRange(location: 0, length: 6), + displayData: TextLine.DisplayData( + maxWidth: 150, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), + markedRanges: nil, + attachments: [.init(range: NSRange(location: 1, length: 1), attachment: attachment)] + ) + + XCTAssertEqual(typesetter.lineFragments.count, 3) + + var fragment = try XCTUnwrap(typesetter.lineFragments.first?.data) + XCTAssertEqual(fragment.contents.count, 1) + XCTAssertTrue(fragment.contents[0].isText) + + fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 1)?.data) + XCTAssertEqual(fragment.contents.count, 1) + XCTAssertFalse(fragment.contents[0].isText) + + fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 2)?.data) + XCTAssertEqual(fragment.contents.count, 1) + XCTAssertTrue(fragment.contents[0].isText) + } + + func test_wrapLinesDoesNotBreakOnLastNewline() throws { + let attachment = DemoTextAttachment(width: 50) + let string = NSAttributedString(string: "AB CD\n12 34\nWX YZ\n", attributes: attributes) + typesetter.typeset( + string, + documentRange: NSRange(location: 0, length: 15), + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .word + ), + markedRanges: nil, + attachments: [.init(range: NSRange(start: 4, end: 15), attachment: attachment)] + ) + + XCTAssertEqual(typesetter.lineFragments.count, 1) + } +}