diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d4a0b1e16..ec2f7f61a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -39,7 +39,7 @@ body: description: >- click on the version number on the welcome screen value: | - CodeEditTextView: [e.g. 0.x.y] + CodeEditSourceEditor: [e.g. 0.x.y] macOS: [e.g. 13.2.1] Xcode: [e.g. 14.2] diff --git a/.github/scripts/build-docc.sh b/.github/scripts/build-docc.sh index b50abd178..86235f1f5 100755 --- a/.github/scripts/build-docc.sh +++ b/.github/scripts/build-docc.sh @@ -2,7 +2,7 @@ export LC_CTYPE=en_US.UTF-8 -set -o pipefail && xcodebuild clean docbuild -scheme CodeEditTextView \ +set -o pipefail && xcodebuild clean docbuild -scheme CodeEditSourceEditor \ -destination generic/platform=macos \ -skipPackagePluginValidation \ - OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path CodeEditTextView --output-path ./docs" | xcpretty + OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path CodeEditSourceEditor --output-path ./docs" | xcpretty diff --git a/.github/scripts/tests.sh b/.github/scripts/tests.sh index c90874f48..af8440aa1 100755 --- a/.github/scripts/tests.sh +++ b/.github/scripts/tests.sh @@ -14,7 +14,7 @@ echo "Building with arch: ${ARCH}" export LC_CTYPE=en_US.UTF-8 set -o pipefail && arch -"${ARCH}" xcodebuild \ - -scheme CodeEditTextView \ + -scheme CodeEditSourceEditor \ -derivedDataPath ".build" \ -destination "platform=macos,arch=${ARCH}" \ -skipPackagePluginValidation \ diff --git a/.github/workflows/CI-pull-request.yml b/.github/workflows/CI-pull-request.yml index 384d52a7e..d36866782 100644 --- a/.github/workflows/CI-pull-request.yml +++ b/.github/workflows/CI-pull-request.yml @@ -10,7 +10,7 @@ jobs: uses: ./.github/workflows/swiftlint.yml secrets: inherit test: - name: Testing CodeEditTextView + name: Testing CodeEditSourceEditor needs: swiftlint uses: ./.github/workflows/tests.yml secrets: inherit diff --git a/.github/workflows/CI-push.yml b/.github/workflows/CI-push.yml index bcff6c073..0abccd0a9 100644 --- a/.github/workflows/CI-push.yml +++ b/.github/workflows/CI-push.yml @@ -10,7 +10,7 @@ jobs: uses: ./.github/workflows/swiftlint.yml secrets: inherit test: - name: Testing CodeEditTextView + name: Testing CodeEditSourceEditor needs: swiftlint uses: ./.github/workflows/tests.yml secrets: inherit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a7bef822e..928f78002 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: workflow_call: jobs: code-edit-text-view-tests: - name: Testing CodeEditTextView + name: Testing CodeEditSourceEditor runs-on: [self-hosted, macOS] steps: - name: Checkout repository diff --git a/Package.resolved b/Package.resolved index a361544d2..7e2a1391e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.17" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "c867fed329b2b4ce91a13742e20626f50cf233bb", + "version" : "0.7.0" + } + }, { "identity" : "mainoffender", "kind" : "remoteSourceControl", @@ -32,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", - "version" : "1.0.4" + "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", + "version" : "1.0.5" } }, { diff --git a/Package.swift b/Package.swift index 1f14ed258..8d8837275 100644 --- a/Package.swift +++ b/Package.swift @@ -4,21 +4,21 @@ import PackageDescription let package = Package( - name: "CodeEditTextView", + name: "CodeEditSourceEditor", platforms: [.macOS(.v13)], products: [ // A source editor with useful features for code editing. .library( - name: "CodeEditTextView", - targets: ["CodeEditTextView"] - ), - // A Fast, Efficient text view for code. - .library( - name: "CodeEditInputView", - targets: ["CodeEditInputView"] + name: "CodeEditSourceEditor", + targets: ["CodeEditSourceEditor"] ) ], dependencies: [ + // A fast, efficient, text view for code. + .package( + url: "https://github.com/CodeEditApp/CodeEditTextView.git", + from: "0.7.0" + ), // tree-sitter languages .package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", @@ -29,28 +29,18 @@ let package = Package( url: "https://github.com/lukepistrol/SwiftLintPlugin", from: "0.2.2" ), - // Text mutation, storage helpers - .package( - url: "https://github.com/ChimeHQ/TextStory", - from: "0.8.0" - ), // Rules for indentation, pair completion, whitespace .package( url: "https://github.com/ChimeHQ/TextFormation", from: "0.8.1" - ), - // Useful data structures - .package( - url: "https://github.com/apple/swift-collections.git", - .upToNextMajor(from: "1.0.0") ) ], targets: [ // A source editor with useful features for code editing. .target( - name: "CodeEditTextView", + name: "CodeEditSourceEditor", dependencies: [ - "CodeEditInputView", + "CodeEditTextView", "CodeEditLanguages", "TextFormation", ], @@ -59,40 +49,16 @@ let package = Package( ] ), - // The underlying text rendering view for CodeEditTextView - .target( - name: "CodeEditInputView", - dependencies: [ - "TextStory", - "TextFormation", - .product(name: "Collections", package: "swift-collections") - ], - plugins: [ - .plugin(name: "SwiftLint", package: "SwiftLintPlugin") - ] - ), - // Tests for the source editor .testTarget( - name: "CodeEditTextViewTests", + name: "CodeEditSourceEditorTests", dependencies: [ - "CodeEditTextView", + "CodeEditSourceEditor", "CodeEditLanguages", ], plugins: [ .plugin(name: "SwiftLint", package: "SwiftLintPlugin") ] ), - - // Tests for the input view - .testTarget( - name: "CodeEditInputViewTests", - dependencies: [ - "CodeEditInputView", - ], - plugins: [ - .plugin(name: "SwiftLint", package: "SwiftLintPlugin") - ] - ), ] ) diff --git a/README.md b/README.md index bdf25c7b6..842058609 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@

- -

CodeEditTextView

+ +

CodeEditSourceEditor

+

@@ -10,7 +11,7 @@ - +

@@ -19,24 +20,24 @@ An Xcode-inspired code editor view written in Swift powered by tree-sitter for [ social-cover-textview -![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 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) +![GitHub release](https://img.shields.io/github/v/release/CodeEditApp/CodeEditSourceEditor?color=orange&label=latest%20release&sort=semver&style=flat-square) +![Github Tests](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditSourceEditor/tests.yml?branch=main&label=tests&style=flat-square) +![Documentation](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditSourceEditor/build-documentation.yml?branch=main&label=docs&style=flat-square) +![GitHub Repo stars](https://img.shields.io/github/stars/CodeEditApp/CodeEditSourceEditor?style=flat-square) +![GitHub forks](https://img.shields.io/github/forks/CodeEditApp/CodeEditSourceEditor?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) -| :warning: | **CodeEditTextView is currently in development and it is not ready for production use.**
Please check back later for updates on this project. Contributors are welcome as we build out the features mentioned above! | +| :warning: | **CodeEditSourceEditor is currently in development and it is not ready for production use.**
Please check back later for updates on this project. Contributors are welcome as we build out the features mentioned above! | | - |:-| ## Documentation -This package is fully documented [here](https://codeeditapp.github.io/CodeEditTextView/documentation/codeedittextview/). +This package is fully documented [here](https://codeeditapp.github.io/CodeEditSourceEditor/documentation/codeeditsourceeditor/). ## Usage ```swift -import CodeEditTextView +import CodeEditSourceEditor struct ContentView: View { @@ -48,7 +49,7 @@ struct ContentView: View { @State var editorOverscroll = 0.3 var body: some View { - CodeEditTextView( + CodeEditSourceEditor( $text, language: .swift, theme: $theme, @@ -87,6 +88,12 @@ Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/ma

        CodeEdit        

+ + + + +

CodeEditTextView

+ @@ -107,4 +114,3 @@ Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/ma - diff --git a/Sources/CodeEditInputView/Documentation.docc/Documentation.md b/Sources/CodeEditInputView/Documentation.docc/Documentation.md deleted file mode 100644 index d7ffa9dd1..000000000 --- a/Sources/CodeEditInputView/Documentation.docc/Documentation.md +++ /dev/null @@ -1,37 +0,0 @@ -# ``CodeEditInputView`` - -A text editor designed to edit code documents. - -## Overview - -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. - -> This package contains a text view suitable for replacing `NSTextView` in some, ***specific*** cases. If you want a text view that can handle things like: left-to-right layout, custom layout elements, or feature parity with the system text view, consider using [STTextView](https://github.com/krzyzanowskim/STTextView) or [NSTextView](https://developer.apple.com/documentation/appkit/nstextview). The ``TextView`` exported by this library is designed to lay out documents made up of lines of text. However, it does not attempt to reason about the contents of the document. If you're looking to edit *source code* (indentation, syntax highlighting) consider using the parent library [CodeEditTextView](https://github.com/CodeEditApp/CodeEditTextView). - -The ``TextView`` class is an `NSView` subclass that can be embedded in a scroll view or used standalone. It parses and renders lines of a document and handles mouse and keyboard events for text editing. It also renders styled strings for use cases like syntax highlighting. - -## Topics - -### Text View - -- ``TextView`` -- ``CEUndoManager`` - -### Text Layout - -- ``TextLayoutManager`` -- ``TextLine`` -- ``LineFragment`` - -### Text Selection - -- ``TextSelectionManager`` -- ``TextSelectionManager/TextSelection`` -- ``CursorView`` - -### Supporting Types - -- ``TextLineStorage`` -- ``HorizontalEdgeInsets`` -- ``LineEnding`` -- ``LineBreakStrategy`` diff --git a/Sources/CodeEditInputView/Extensions/NSRange+isEmpty.swift b/Sources/CodeEditInputView/Extensions/NSRange+isEmpty.swift deleted file mode 100644 index 8863c996c..000000000 --- a/Sources/CodeEditInputView/Extensions/NSRange+isEmpty.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// NSRange+isEmpty.swift -// -// -// Created by Khan Winter on 8/23/23. -// - -import Foundation - -public extension NSRange { - var isEmpty: Bool { - length == 0 - } -} diff --git a/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift b/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift deleted file mode 100644 index b7256d565..000000000 --- a/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// NSTextStorage+getLine.swift -// -// -// Created by Khan Winter on 9/3/23. -// - -import AppKit - -extension NSString { - func getNextLine(startingAt location: Int) -> NSRange? { - let range = NSRange(location: location, length: 0) - var end: Int = NSNotFound - var contentsEnd: Int = NSNotFound - self.getLineStart(nil, end: &end, contentsEnd: &contentsEnd, for: range) - if end != NSNotFound && contentsEnd != NSNotFound && end != contentsEnd { - return NSRange(location: contentsEnd, length: end - contentsEnd) - } else { - return nil - } - } -} - -extension NSTextStorage { - func getNextLine(startingAt location: Int) -> NSRange? { - (self.string as NSString).getNextLine(startingAt: location) - } -} diff --git a/Sources/CodeEditInputView/Extensions/PixelAligned.swift b/Sources/CodeEditInputView/Extensions/PixelAligned.swift deleted file mode 100644 index 80e501c99..000000000 --- a/Sources/CodeEditInputView/Extensions/PixelAligned.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// PixelAligned.swift -// -// -// Created by Khan Winter on 9/10/23. -// - -import Foundation - -public extension NSRect { - /// Creates a rect pixel-aligned on all edges. - var pixelAligned: NSRect { - NSIntegralRectWithOptions(self, .alignAllEdgesNearest) - } -} - -public extension NSPoint { - /// Creates a point that's pixel-aligned. - var pixelAligned: NSPoint { - NSIntegralRectWithOptions(NSRect(x: self.x, y: self.y, width: 0, height: 0), .alignAllEdgesNearest).origin - } -} diff --git a/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift b/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift deleted file mode 100644 index 09be2d6a7..000000000 --- a/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// MarkedTextManager.swift -// -// -// Created by Khan Winter on 11/7/23. -// - -import AppKit - -/// Manages marked ranges -class MarkedTextManager { - struct MarkedRanges { - let ranges: [NSRange] - let attributes: [NSAttributedString.Key: Any] - } - - /// All marked ranges being tracked. - private(set) var markedRanges: [NSRange] = [] - - /// The attributes to use for marked text. Defaults to a single underline when `nil` - var markedTextAttributes: [NSAttributedString.Key: Any]? - - /// True if there is marked text being tracked. - var hasMarkedText: Bool { - !markedRanges.isEmpty - } - - /// Removes all marked ranges. - func removeAll() { - markedRanges.removeAll() - } - - /// Updates the stored marked ranges. - /// - Parameters: - /// - insertLength: The length of the string being inserted. - /// - replacementRange: The range to replace with marked text. - /// - selectedRange: The selected range from `NSTextInput`. - /// - textSelections: The current text selections. - func updateMarkedRanges( - insertLength: Int, - replacementRange: NSRange, - selectedRange: NSRange, - textSelections: [TextSelectionManager.TextSelection] - ) { - if replacementRange.location == NSNotFound { - markedRanges = textSelections.map { - NSRange(location: $0.range.location, length: insertLength) - } - } else { - markedRanges = [selectedRange] - } - } - - /// Finds any marked ranges for a line and returns them. - /// - Parameter lineRange: The range of the line. - /// - Returns: A `MarkedRange` struct with information about attributes and ranges. `nil` if there is no marked - /// text for this line. - func markedRanges(in lineRange: NSRange) -> MarkedRanges? { - let attributes = markedTextAttributes ?? [.underlineStyle: NSUnderlineStyle.single.rawValue] - let ranges = markedRanges.compactMap { - $0.intersection(lineRange) - }.map { - NSRange(location: $0.location - lineRange.location, length: $0.length) - } - if ranges.isEmpty { - return nil - } else { - return MarkedRanges(ranges: ranges, attributes: attributes) - } - } - - /// Updates marked text ranges for a new set of selections. - /// - Parameter textSelections: The new text selections. - /// - Returns: `True` if the marked text needs layout. - func updateForNewSelections(textSelections: [TextSelectionManager.TextSelection]) -> Bool { - // Ensure every marked range has a matching selection. - // If any marked ranges do not have a matching selection, unmark. - // Matching, in this context, means having a selection in the range location...max - var markedRanges = markedRanges - for textSelection in textSelections { - if let markedRangeIdx = markedRanges.firstIndex(where: { - ($0.location...$0.max).contains(textSelection.range.location) - && ($0.location...$0.max).contains(textSelection.range.max) - }) { - markedRanges.remove(at: markedRangeIdx) - } else { - return true - } - } - - // If any remaining marked ranges, we need to unmark. - if !markedRanges.isEmpty { - return false - } else { - return true - } - } -} diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift deleted file mode 100644 index 0b00d2c52..000000000 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// TextLayoutManager+Edits.swift -// -// -// Created by Khan Winter on 9/3/23. -// - -import AppKit - -// MARK: - Edits - -extension TextLayoutManager: NSTextStorageDelegate { - /// Notifies the layout manager of an edit. - /// - /// 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. - /// - /// - Parameters: - /// - range: The range of the edit. - /// - string: The string to replace in the given range. - public func willReplaceCharactersInRange(range: NSRange, with string: String) { - // Loop through each line being replaced in reverse, updating and removing where necessary. - 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 { - // Delete line - lineStorage.delete(lineAt: linePosition.range.location) - } else if intersection.max == linePosition.range.max, - let nextLine = lineStorage.getLine(atOffset: linePosition.range.max) { - // Need to merge line with one after it after updating this line to remove the end of the line - 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) - } - } else { - lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0) - } - } - - // 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(location: index, length: nextLine.max - index) - applyLineInsert((string as NSString).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 - ) - } - } - setNeedsLayout() - } - - /// Applies a line insert to the internal line storage tree. - /// - Parameters: - /// - insertedString: The string being inserted. - /// - 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 { - // 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.insert( - line: TextLine(), - atOffset: location + insertedString.length, - length: 0, - height: estimateLineHeight() - ) - } else { - // Need to split the line inserting into and create a new line with the split section of the line - guard let linePosition = lineStorage.getLine(atOffset: location) else { return } - let splitLocation = location + insertedString.length - 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.insert( - line: TextLine(), - atOffset: splitLocation, - length: splitLength, - height: estimateLineHeight() - ) - } - } 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) - } - } -} diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift deleted file mode 100644 index 40f6f738c..000000000 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// TextLayoutManager+Iterator.swift -// -// -// Created by Khan Winter on 8/21/23. -// - -import Foundation - -public extension TextLayoutManager { - func visibleLines() -> Iterator { - 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) - } - - struct Iterator: LazySequenceProtocol, IteratorProtocol { - private var storageIterator: TextLineStorage.TextLineStorageYIterator - - init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage) { - storageIterator = storage.linesStartingAt(minY, until: maxY) - } - - public mutating func next() -> TextLineStorage.TextLinePosition? { - storageIterator.next() - } - } -} diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift deleted file mode 100644 index 97af1d1ed..000000000 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// TextLayoutManager+Public.swift -// -// -// Created by Khan Winter on 9/13/23. -// - -import AppKit - -extension TextLayoutManager { - public func estimatedHeight() -> CGFloat { - max(lineStorage.height, estimateLineHeight()) - } - - public func estimatedWidth() -> CGFloat { - maxLineWidth - } - - public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? { - lineStorage.getLine(atPosition: posY) - } - - public func textLineForOffset(_ offset: Int) -> TextLineStorage.TextLinePosition? { - if offset == lineStorage.length { - return lineStorage.last - } else { - return lineStorage.getLine(atOffset: offset) - } - } - - /// Finds text line and returns it if found. - /// Lines are 0 indexed. - /// - Parameter index: The line to find. - /// - 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) - } - - public func textOffsetAtPoint(_ point: CGPoint) -> Int? { - 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 - ) else { - return nil - } - 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 - } - } else { - // Somewhere in the fragment - let fragmentIndex = CTLineGetStringIndexForPosition( - fragment.ctLine, - CGPoint(x: point.x - edgeInsets.left, y: fragment.height/2) - ) - return position.range.location + fragmentIndex - } - } - - /// 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 - /// emoji or non-latin glyph. - /// - 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 { - return rectForEndOffset() - } - guard let linePosition = lineStorage.getLine(atOffset: offset) 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 - } - - // 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 maxXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRange.max - linePosition.range.location, - nil - ) - - return CGRect( - x: minXPos + edgeInsets.left, - y: linePosition.yPos + fragmentPosition.yPos, - width: maxXPos - minXPos, - height: fragmentPosition.data.scaledHeight - ) - } - - /// Finds a suitable cursor rect for the end position. - /// - Returns: A CGRect if it could be created. - private func rectForEndOffset() -> CGRect? { - if let last = lineStorage.last { - if last.range.isEmpty { - // Return a 0-width rect at the end of the last line. - return CGRect(x: edgeInsets.left, y: last.yPos, width: 0, height: last.height) - } else if let rect = rectForOffset(last.range.max - 1) { - return CGRect(x: rect.maxX, y: rect.minY, width: 0, height: rect.height) - } - } else if lineStorage.isEmpty { - // Text is empty, create a new rect with estimated height at the origin - return CGRect( - x: edgeInsets.left, - y: 0.0, - width: 0, - height: estimateLineHeight() - ) - } - 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( - location: startingLinePosition.range.location, - length: linePosition.range.max - startingLinePosition.range.location - ) - ) { - let height = ensureLayoutFor(position: linePosition) - if height != linePosition.height { - lineStorage.update( - atIndex: linePosition.range.location, - delta: 0, - deltaHeight: height - linePosition.height - ) - } - } - - if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - } - - /// 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 - } - return height - } -} diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift deleted file mode 100644 index a7ae3cb63..000000000 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ /dev/null @@ -1,374 +0,0 @@ -// -// TextLayoutManager.swift -// -// -// Created by Khan Winter on 6/21/23. -// - -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 - - public weak var delegate: TextLayoutManagerDelegate? - public var lineHeightMultiplier: CGFloat { - didSet { - setNeedsLayout() - } - } - public var wrapLines: Bool { - didSet { - setNeedsLayout() - } - } - public var detectedLineEnding: LineEnding = .lineFeed - /// The edge insets to inset all text layout with. - public var edgeInsets: HorizontalEdgeInsets = .zero { - didSet { - delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth + edgeInsets.horizontal) - setNeedsLayout() - } - } - - /// The number of lines in the document - public var lineCount: Int { - lineStorage.count - } - - /// The strategy to use when breaking lines. Defaults to ``LineBreakStrategy/word``. - public var lineBreakStrategy: LineBreakStrategy = .word { - didSet { - setNeedsLayout() - } - } - - // MARK: - Internal - - weak var textStorage: NSTextStorage? - var lineStorage: TextLineStorage = TextLineStorage() - var markedTextManager: MarkedTextManager = MarkedTextManager() - private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() - private var visibleLineIds: Set = [] - /// Used to force a complete re-layout using `setNeedsLayout` - private var needsLayout: Bool = false - - private var transactionCounter: Int = 0 - public var isInTransaction: Bool { - transactionCounter > 0 - } - - weak var layoutView: NSView? - - /// The calculated maximum width of all laid out lines. - /// - Note: This does not indicate *the* maximum width of the text view if all lines have not been laid out. - /// This will be updated if it comes across a wider line. - var maxLineWidth: CGFloat = 0 { - didSet { - delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth + edgeInsets.horizontal) - } - } - /// The maximum width available to lay out lines in. - var maxLineLayoutWidth: CGFloat { - wrapLines ? (delegate?.textViewportSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal - : .greatestFiniteMagnitude - } - - /// 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. - /// - Parameters: - /// - textStorage: The text storage object to use as a data source. - /// - lineHeightMultiplier: The multiplier to use for line heights. - /// - 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( - textStorage: NSTextStorage, - lineHeightMultiplier: CGFloat, - wrapLines: Bool, - textView: NSView, - delegate: TextLayoutManagerDelegate? - ) { - self.textStorage = textStorage - self.lineHeightMultiplier = lineHeightMultiplier - self.wrapLines = wrapLines - self.layoutView = textView - self.delegate = delegate - super.init() - prepareTextLines() - } - - /// Prepares the layout manager for use. - /// Parses the text storage object into lines and builds the `lineStorage` object from those lines. - func prepareTextLines() { - guard lineStorage.count == 0, let textStorage else { return } - #if DEBUG - // Grab some performance information if debugging. - var info = mach_timebase_info() - guard mach_timebase_info(&info) == KERN_SUCCESS else { return } - let start = mach_absolute_time() - #endif - - lineStorage.buildFromTextStorage(textStorage, estimatedLineHeight: estimateLineHeight()) - detectedLineEnding = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: textStorage) - - #if DEBUG - 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") - #endif - } - - /// Resets the layout manager to an initial state. - func reset() { - lineStorage.removeAll() - visibleLineIds.removeAll() - viewReuseQueue.queuedViews.removeAll() - viewReuseQueue.usedViews.removeAll() - maxLineWidth = 0 - markedTextManager.removeAll() - prepareTextLines() - setNeedsLayout() - } - - /// Estimates the line height for the current typing attributes. - /// Takes into account ``TextLayoutManager/lineHeightMultiplier``. - /// - Returns: The estimated line height. - public func estimateLineHeight() -> CGFloat { - if let _estimateLineHeight { - return _estimateLineHeight - } else { - let string = NSAttributedString(string: "0", attributes: delegate?.layoutManagerTypingAttributes() ?? [:]) - let typesetter = CTTypesetterCreateWithAttributedString(string) - let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 1)) - var ascent: CGFloat = 0 - var descent: CGFloat = 0 - var leading: CGFloat = 0 - CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading) - _estimateLineHeight = (ascent + descent + leading) * lineHeightMultiplier - return _estimateLineHeight! - } - } - - /// The last known line height estimate. If set to `nil`, will be recalculated the next time - /// ``TextLayoutManager/estimateLineHeight()`` is called. - private var _estimateLineHeight: CGFloat? - - // MARK: - Invalidation - - /// Invalidates layout for the given rect. - /// - Parameter rect: The rect to invalidate. - public func invalidateLayoutForRect(_ rect: NSRect) { - for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) { - linePosition.data.setNeedsLayout() - } - layoutLines() - } - - /// Invalidates layout for the given range of text. - /// - Parameter range: The range of text to invalidate. - public func invalidateLayoutForRange(_ range: NSRange) { - for linePosition in lineStorage.linesInRange(range) { - linePosition.data.setNeedsLayout() - } - - layoutLines() - } - - public func setNeedsLayout() { - needsLayout = true - visibleLineIds.removeAll(keepingCapacity: true) - } - - /// 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() { - transactionCounter -= 1 - if transactionCounter == 0 { - setNeedsLayout() - layoutLines() - } else if transactionCounter < 0 { - // swiftlint:disable:next line_length - assertionFailure("TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call") - } - } - - // MARK: - Layout - - /// Lays out all visible lines - func layoutLines() { // swiftlint:disable:this function_body_length - guard let visibleRect = delegate?.visibleRect, !isInTransaction, let textStorage else { return } - CATransaction.begin() - let minY = max(visibleRect.minY, 0) - let maxY = max(visibleRect.maxY, 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 - for linePosition in lineStorage.linesStartingAt(minY, until: maxY) { - // Updating height in the loop may cause the iterator to be wrong - 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: linePosition.yPos, 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.typesetter.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 originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - - if maxFoundLineWidth > maxLineWidth { - maxLineWidth = maxFoundLineWidth - } - - if yContentAdjustment != 0 { - delegate?.layoutManagerYAdjustment(yContentAdjustment) - } - - needsLayout = false - CATransaction.commit() - } - - /// 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. - /// - minY: The minimum Y value to start at. - /// - maxY: The maximum Y value to end layout at. - /// - maxWidth: The maximum layout width, infinite if ``TextLayoutManager/wrapLines`` is `false`. - /// - 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 - - // TODO: Lay out only fragments in min/max Y - for lineFragmentPosition in line.typesetter.lineFragments { - let lineFragment = lineFragmentPosition.data - - layoutFragmentView(for: lineFragmentPosition, at: layoutData.minY + 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 - delegate = nil - } -} diff --git a/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift b/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift deleted file mode 100644 index d64e0d93f..000000000 --- a/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// LineBreakStrategy.swift -// -// -// Created by Khan Winter on 9/19/23. -// - -/// Options for breaking lines when they cannot fit in the viewport. -public enum LineBreakStrategy { - /// Break lines at word boundaries when possible. - case word - /// Break lines at the nearest character, regardless of grouping. - case character -} diff --git a/Sources/CodeEditInputView/TextLine/LineFragment.swift b/Sources/CodeEditInputView/TextLine/LineFragment.swift deleted file mode 100644 index e8905b6ee..000000000 --- a/Sources/CodeEditInputView/TextLine/LineFragment.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// LineFragment.swift -// -// -// Created by Khan Winter on 6/29/23. -// - -import AppKit - -/// 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 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 - - /// The difference between the real text height and the scaled height - public var heightDifference: CGFloat { - scaledHeight - height - } - - init( - ctLine: CTLine, - width: CGFloat, - height: CGFloat, - descent: CGFloat, - lineHeightMultiplier: CGFloat - ) { - self.ctLine = ctLine - self.width = width - self.height = height - self.descent = descent - self.scaledHeight = height * lineHeightMultiplier - } - - public static func == (lhs: LineFragment, rhs: LineFragment) -> Bool { - lhs.id == rhs.id - } -} diff --git a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift deleted file mode 100644 index 3d95eea6c..000000000 --- a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// LineFragmentView.swift -// -// -// Created by Khan Winter on 8/14/23. -// - -import AppKit - -/// Displays a line fragment. -final class LineFragmentView: NSView { - private weak var lineFragment: LineFragment? - - override var isFlipped: Bool { - true - } - - override var isOpaque: Bool { - false - } - - /// Prepare the view for reuse, clears the line fragment reference. - override func prepareForReuse() { - super.prepareForReuse() - lineFragment = nil - - } - - /// Set a new line fragment for this view, updating view size. - /// - Parameter newFragment: The new fragment to use. - public func setLineFragment(_ newFragment: LineFragment) { - self.lineFragment = newFragment - self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight) - } - - /// Draws the line fragment in the graphics context. - override func draw(_ dirtyRect: NSRect) { - guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else { - return - } - context.saveGState() - context.setAllowsFontSmoothing(true) - context.setShouldSmoothFonts(true) - 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() - } -} diff --git a/Sources/CodeEditInputView/TextLine/TextLine.swift b/Sources/CodeEditInputView/TextLine/TextLine.swift deleted file mode 100644 index 977b9741d..000000000 --- a/Sources/CodeEditInputView/TextLine/TextLine.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// TextLine.swift -// -// -// Created by Khan Winter on 6/21/23. -// - -import Foundation -import AppKit - -/// Represents a displayable line of text. -public final class TextLine: Identifiable, Equatable { - public let id: UUID = UUID() - private var needsLayout: Bool = true - var maxWidth: CGFloat? - private(set) var typesetter: Typesetter = Typesetter() - - /// The line fragments contained by this text line. - public var lineFragments: TextLineStorage { - typesetter.lineFragments - } - - /// Marks this line as needing layout and clears all typesetting data. - public func setNeedsLayout() { - needsLayout = true - typesetter = Typesetter() - } - - /// Determines if the line needs to be laid out again. - /// - Parameter maxWidth: The new max width to check. - /// - 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 - } - - /// Prepares the line for display, generating all potential line breaks and calculating the real height of the line. - /// - Parameters: - /// - displayData: Information required to display a text line. - /// - 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( - displayData: DisplayData, - range: NSRange, - stringRef: NSTextStorage, - markedRanges: MarkedTextManager.MarkedRanges?, - breakStrategy: LineBreakStrategy - ) { - let string = stringRef.attributedSubstring(from: range) - self.maxWidth = displayData.maxWidth - typesetter.typeset( - string, - displayData: displayData, - breakStrategy: breakStrategy, - markedRanges: markedRanges - ) - needsLayout = false - } - - public static func == (lhs: TextLine, rhs: TextLine) -> Bool { - lhs.id == rhs.id - } - - /// 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 - } -} diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift deleted file mode 100644 index 4f46965ba..000000000 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// Typesetter.swift -// -// -// 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(location: startIndex, length: lineBreak - startIndex), - 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 { - let breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth) - if breakIndex >= string.length || (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1)) { - // 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: .whitespaces) || set.isSubset(of: .punctuationCharacters) - } - - deinit { - lineFragments.removeAll() - } -} diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift deleted file mode 100644 index 34377bd4b..000000000 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// File.swift -// -// -// Created by Khan Winter on 7/16/23. -// - -import Foundation - -public extension TextLineStorage { - func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator { - TextLineStorageYIterator(storage: self, minY: minY, maxY: maxY) - } - - func linesInRange(_ range: NSRange) -> TextLineStorageRangeIterator { - TextLineStorageRangeIterator(storage: self, range: range) - } - - struct TextLineStorageYIterator: LazySequenceProtocol, IteratorProtocol { - private let storage: TextLineStorage - private let minY: CGFloat - private let maxY: CGFloat - private var currentPosition: TextLinePosition? - - init(storage: TextLineStorage, minY: CGFloat, maxY: CGFloat, currentPosition: TextLinePosition? = nil) { - self.storage = storage - self.minY = minY - self.maxY = maxY - self.currentPosition = currentPosition - } - - public mutating func next() -> TextLinePosition? { - if let currentPosition { - guard currentPosition.yPos < maxY, - let nextPosition = storage.getLine(atOffset: currentPosition.range.max), - nextPosition.index != currentPosition.index else { - return nil - } - self.currentPosition = nextPosition - return self.currentPosition! - } else if let nextPosition = storage.getLine(atPosition: minY) { - self.currentPosition = nextPosition - return nextPosition - } else { - return nil - } - } - } - - struct TextLineStorageRangeIterator: LazySequenceProtocol, IteratorProtocol { - private let storage: TextLineStorage - private let range: NSRange - private var currentPosition: TextLinePosition? - - init(storage: TextLineStorage, range: NSRange, currentPosition: TextLinePosition? = nil) { - self.storage = storage - self.range = range - self.currentPosition = currentPosition - } - - public mutating func next() -> TextLinePosition? { - if let currentPosition { - guard currentPosition.range.max < range.max, - let nextPosition = storage.getLine(atOffset: currentPosition.range.max) else { - return nil - } - self.currentPosition = nextPosition - return self.currentPosition! - } else if let nextPosition = storage.getLine(atOffset: range.location) { - self.currentPosition = nextPosition - return nextPosition - } else { - return nil - } - } - } -} - -extension TextLineStorage: LazySequenceProtocol { - public func makeIterator() -> TextLineStorageIterator { - TextLineStorageIterator(storage: self, currentPosition: nil) - } - - public struct TextLineStorageIterator: IteratorProtocol { - private let storage: TextLineStorage - private var currentPosition: TextLinePosition? - - init(storage: TextLineStorage, currentPosition: TextLinePosition? = nil) { - self.storage = storage - self.currentPosition = currentPosition - } - - public mutating func next() -> TextLinePosition? { - if let currentPosition { - guard currentPosition.range.max < storage.length, - let nextPosition = storage.getLine(atOffset: currentPosition.range.max) else { - return nil - } - self.currentPosition = nextPosition - return self.currentPosition! - } else if let nextPosition = storage.getLine(atOffset: 0) { - self.currentPosition = nextPosition - return nextPosition - } else { - return nil - } - } - } -} diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift deleted file mode 100644 index abf05b07e..000000000 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// TextLineStorage+NSTextStorage.swift -// -// -// Created by Khan Winter on 8/21/23. -// - -import AppKit - -extension TextLineStorage where Data == TextLine { - /// Builds the line storage object from the given `NSTextStorage`. - /// - Parameters: - /// - textStorage: The text storage object to use. - /// - estimatedLineHeight: The estimated height of each individual line. - func buildFromTextStorage(_ textStorage: NSTextStorage, estimatedLineHeight: CGFloat) { - var index = 0 - var lines: [BuildItem] = [] - while let range = textStorage.getNextLine(startingAt: index) { - lines.append(BuildItem(data: TextLine(), length: range.max - index, height: estimatedLineHeight)) - index = NSMaxRange(range) - } - // Create the last line - if textStorage.length - index > 0 { - lines.append(BuildItem(data: TextLine(), length: textStorage.length - index, height: estimatedLineHeight)) - } - - if textStorage.length == 0 - || LineEnding(rawValue: textStorage.mutableString.substring(from: textStorage.length - 1)) != nil { - lines.append(BuildItem(data: TextLine(), length: 0, height: estimatedLineHeight)) - } - - // Use an efficient tree building algorithm rather than adding lines sequentially - self.build(from: lines, estimatedLineHeight: estimatedLineHeight) - } -} diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift deleted file mode 100644 index 745cfec3a..000000000 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// TextLineStorage+Node.swift -// -// -// Created by Khan Winter on 6/25/23. -// - -import Foundation - -extension TextLineStorage { - func isRightChild(_ node: Node) -> Bool { - node.parent?.right === node - } - - func isLeftChild(_ node: Node) -> Bool { - node.parent?.left === node - } - - /// Transplants a node with another node. - /// - /// ``` - /// [a] - /// [u]_/ \_[b] - /// [c]_/ \_[v] - /// - /// call: transplant(u, v) - /// - /// [a] - /// [v]_/ \_[b] - /// [c]_/ - /// - /// ``` - /// - Note: Leaves the task of updating tree metadata to the caller. - /// - Parameters: - /// - nodeU: The node to replace. - /// - nodeV: The node to insert in place of `nodeU` - func transplant(_ nodeU: borrowing Node, with nodeV: Node?) { - if nodeU.parent == nil { - root = nodeV - } else if isLeftChild(nodeU) { - nodeU.parent?.left = nodeV - } else { - nodeU.parent?.right = nodeV - } - nodeV?.parent = nodeU.parent - } - - enum Color { - case red - case black - } - - final class Node { - // The length of the text line - var length: Int - // The height of this text line - var height: CGFloat - var data: NodeData - - // The offset in characters of the entire left subtree - var leftSubtreeOffset: Int - // The sum of the height of the nodes in the left subtree - var leftSubtreeHeight: CGFloat - // The number of nodes in the left subtree - var leftSubtreeCount: Int - - var left: Node? - var right: Node? - unowned var parent: Node? - var color: Color - - init( - length: Int, - data: NodeData, - leftSubtreeOffset: Int, - leftSubtreeHeight: CGFloat, - leftSubtreeCount: Int, - height: CGFloat, - left: Node? = nil, - right: Node? = nil, - parent: Node? = nil, - color: Color - ) { - self.length = length - self.data = data - self.leftSubtreeOffset = leftSubtreeOffset - self.leftSubtreeHeight = leftSubtreeHeight - self.leftSubtreeCount = leftSubtreeCount - self.height = height - self.left = left - self.right = right - self.parent = parent - self.color = color - } - - convenience init(length: Int, data: NodeData, height: CGFloat) { - self.init( - length: length, - data: data, - leftSubtreeOffset: 0, - leftSubtreeHeight: 0.0, - leftSubtreeCount: 0, - height: height, - color: .black - ) - } - - func sibling() -> Node? { - if parent?.left === self { - return parent?.right - } else { - return parent?.left - } - } - - func minimum() -> Node { - if let left { - return left.minimum() - } else { - return self - } - } - - func maximum() -> Node { - if let right { - return right.maximum() - } else { - return self - } - } - - func getSuccessor() -> Node? { - // If node has right child: successor is the min of this right tree - if let right { - return right.minimum() - } else { - // Else go upward until node is a left child - var currentNode = self - var parent = currentNode.parent - while currentNode.parent?.right === currentNode { - if let parent = parent { - currentNode = parent - } - parent = currentNode.parent - } - return parent - } - } - - deinit { - left = nil - right = nil - parent = nil - } - } -} diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift deleted file mode 100644 index c98f3dd54..000000000 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// File.swift -// -// -// Created by Khan Winter on 8/24/23. -// - -import Foundation - -extension TextLineStorage where Data: Identifiable { - public struct TextLinePosition { - init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) { - self.data = data - self.range = range - self.yPos = yPos - self.height = height - self.index = index - } - - init(position: NodePosition) { - self.data = position.node.data - self.range = NSRange(location: position.textPos, length: position.node.length) - self.yPos = position.yPos - self.height = position.node.height - self.index = position.index - } - - /// The data stored at the position - public let data: Data - /// The range represented by the data - public let range: NSRange - /// The y position of the data, on a top down y axis - public let yPos: CGFloat - /// The height of the stored data - public let height: CGFloat - /// The index of the position. - public let index: Int - } - - struct NodePosition { - /// The node storing information and the data stored at the position. - let node: Node - /// The y position of the data, on a top down y axis - let yPos: CGFloat - /// The location of the node in the document - let textPos: Int - /// The index of the node in the document. - let index: Int - } - - struct NodeSubtreeMetadata { - let height: CGFloat - let offset: Int - let count: Int - - static var zero: NodeSubtreeMetadata { - NodeSubtreeMetadata(height: 0, offset: 0, count: 0) - } - - static func + (lhs: NodeSubtreeMetadata, rhs: NodeSubtreeMetadata) -> NodeSubtreeMetadata { - NodeSubtreeMetadata( - height: lhs.height + rhs.height, - offset: lhs.offset + rhs.offset, - count: lhs.count + rhs.count - ) - } - } - - public struct BuildItem { - public let data: Data - public let length: Int - public let height: CGFloat? - } -} diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift deleted file mode 100644 index 273362364..000000000 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ /dev/null @@ -1,632 +0,0 @@ -// -// TextLayoutLineStorage.swift -// -// -// Created by Khan Winter on 6/25/23. -// - -import Foundation - -// Disabling the file length here due to the fact that we want to keep certain methods private even to this package. -// Specifically, all rotation methods, fixup methods, and internal search methods must be kept private. -// swiftlint:disable file_length - -// There is some ugly `Unmanaged` code in this class. This is due to the fact that Swift often has a hard time -// optimizing retain/release calls for object trees. For instance, the `metaFixup` method has a lot of retain/release -// calls to each node/parent as we do a little walk up the tree. -// -// Using Unmanaged references resulted in a -15% decrease (0.667s -> 0.563s) in the -// TextLayoutLineStorageTests.test_insertPerformance benchmark when first changed to use Unmanaged. -// -// See: -// - https://github.com/apple/swift/blob/main/docs/OptimizationTips.rst#unsafe-code -// - https://forums.swift.org/t/improving-linked-list-performance-swift-release-and-swift-retain-overhead/17205 - -/// Implements a red-black tree for efficiently editing, storing and retrieving lines of text in a document. -public final class TextLineStorage { - private enum MetaFixupAction { - case inserted - case deleted - case none - } - - var root: Node? - - /// The number of characters in the storage object. - private(set) public var length: Int = 0 - /// The number of lines in the storage object - private(set) public var count: Int = 0 - - public var isEmpty: Bool { count == 0 } - - public var height: CGFloat = 0 - - public var first: TextLinePosition? { - guard count > 0, let position = search(forIndex: 0) else { return nil } - return TextLinePosition(position: position) - } - - public var last: TextLinePosition? { - guard count > 0, let position = search(forIndex: count - 1) else { return nil } - return TextLinePosition(position: position) - } - - private var lastNode: NodePosition? { - guard count > 0, let position = search(forIndex: count - 1) else { return nil } - return position - } - - public init() { } - - // MARK: - Public Methods - - /// Inserts a new line for the given range. - /// - Complexity: `O(log n)` where `n` is the number of lines in the storage object. - /// - Parameters: - /// - line: The text line to insert - /// - index: The offset to insert the line at. - /// - length: The length of the new line. - /// - height: The height of the new line. - public func insert(line: Data, atOffset index: Int, length: Int, height: CGFloat) { - assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") - defer { - self.count += 1 - self.length += length - self.height += height - } - - let insertedNode = Node(length: length, data: line, height: height) - guard root != nil else { - root = insertedNode - return - } - insertedNode.color = .red - - var currentNode: Unmanaged> = Unmanaged>.passUnretained(root!) - var shouldContinue = true - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - while shouldContinue { - let node = currentNode.takeUnretainedValue() - if currentOffset >= index { - if node.left != nil { - currentNode = Unmanaged>.passUnretained(node.left!) - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) - } else { - node.left = insertedNode - insertedNode.parent = node - shouldContinue = false - } - } else { - if node.right != nil { - currentNode = Unmanaged>.passUnretained(node.right!) - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) - } else { - node.right = insertedNode - insertedNode.parent = node - shouldContinue = false - } - } - } - - metaFixup( - startingAt: insertedNode, - delta: insertedNode.length, - deltaHeight: insertedNode.height, - nodeAction: .inserted - ) - insertFixup(node: insertedNode) - } - - /// Fetches a line for the given offset. - /// - /// - Complexity: `O(log n)` - /// - Parameter offset: The offset to fetch for. - /// - Returns:A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. - public func getLine(atOffset offset: Int) -> TextLinePosition? { - guard let nodePosition = search(for: offset) else { return nil } - return TextLinePosition(position: nodePosition) - } - - /// Fetches a line for the given index. - /// - /// - Complexity: `O(log n)` - /// - Parameter index: The index to fetch for. - /// - Returns: A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. - public func getLine(atIndex index: Int) -> TextLinePosition? { - guard let nodePosition = search(forIndex: index) else { return nil } - return TextLinePosition(position: nodePosition) - } - - /// Fetches a line for the given `y` value. - /// - /// - Complexity: `O(log n)` - /// - Parameter position: The position to fetch for. - /// - Returns: A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. - public func getLine(atPosition posY: CGFloat) -> TextLinePosition? { - guard posY < height else { - return last - } - - var currentNode = root - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 - var currentIndex: Int = root?.leftSubtreeCount ?? 0 - while let node = currentNode { - // If index is in the range [currentOffset..= currentYPosition && posY < currentYPosition + node.height { - return TextLinePosition( - data: node.data, - range: NSRange(location: currentOffset, length: node.length), - yPos: currentYPosition, - height: node.height, - index: currentIndex - ) - } else if currentYPosition > posY { - currentNode = node.left - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) - currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) - currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) - } else if node.leftSubtreeHeight < posY { - currentNode = node.right - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) - currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) - currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) - } else { - currentNode = nil - } - } - - return nil - } - - /// Applies a length change at the given index. - /// - /// If a character was deleted, delta should be negative. - /// The `index` parameter should represent where the edit began. - /// - /// Lines will be deleted if the delta is both negative and encompasses the entire line. - /// - /// If the delta goes beyond the line's range, an error will be thrown. - /// - 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 - /// - 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)") - 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 - position = lastNode - } else { - position = search(for: index) - } - guard let position else { - assertionFailure("No line found at index \(index)") - 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." - ) - } - length += delta - height += deltaHeight - position.node.length += delta - position.node.height += deltaHeight - metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight) - } - - /// Deletes the line containing the given index. - /// - /// Will exit silently if a line could not be found for the given index, and throw an assertion error if the index - /// is out of bounds. - /// - Parameter index: The index to delete a line at. - public func delete(lineAt index: Int) { - assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") - guard count > 1 else { - removeAll() - return - } - guard let node = search(for: index)?.node else { - assertionFailure("Failed to find node for index: \(index)") - return - } - count -= 1 - length -= node.length - height -= node.height - deleteNode(node) - } - - public func removeAll() { - root = nil - count = 0 - length = 0 - height = 0 - } - - /// Efficiently builds the tree from the given array of lines. - /// - Note: Calls ``TextLineStorage/removeAll()`` before building. - /// - Parameter lines: The lines to use to build the tree. - public func build(from lines: borrowing [BuildItem], estimatedLineHeight: CGFloat) { - removeAll() - root = build(lines: lines, estimatedLineHeight: estimatedLineHeight, left: 0, right: lines.count, parent: nil).0 - count = lines.count - } - - /// Recursively builds a subtree given an array of sorted lines, and a left and right indexes. - /// - Parameters: - /// - lines: The lines to use to build the subtree. - /// - estimatedLineHeight: An estimated line height to add to the allocated nodes. - /// - left: The left index to use. - /// - right: The right index to use. - /// - parent: The parent of the subtree, `nil` if this is the root. - /// - Returns: A node, if available, along with it's subtree's height and offset. - private func build( - lines: borrowing [BuildItem], - estimatedLineHeight: CGFloat, - left: Int, - right: Int, - parent: Node? - ) -> (Node?, Int?, CGFloat?, Int) { // swiftlint:disable:this large_tuple - guard left < right else { return (nil, nil, nil, 0) } - let mid = left + (right - left)/2 - let node = Node( - length: lines[mid].length, - data: lines[mid].data, - leftSubtreeOffset: 0, - leftSubtreeHeight: 0, - leftSubtreeCount: 0, - height: lines[mid].height ?? estimatedLineHeight, - color: .black - ) - node.parent = parent - - let (left, leftOffset, leftHeight, leftCount) = build( - lines: lines, - estimatedLineHeight: estimatedLineHeight, - left: left, - right: mid, - parent: node - ) - let (right, rightOffset, rightHeight, rightCount) = build( - lines: lines, - estimatedLineHeight: estimatedLineHeight, - left: mid + 1, - right: right, - parent: node - ) - node.left = left - node.right = right - - if node.left == nil && node.right == nil { - node.color = .red - } - - length += node.length - height += node.height - node.leftSubtreeOffset = leftOffset ?? 0 - node.leftSubtreeHeight = leftHeight ?? 0 - node.leftSubtreeCount = leftCount - - return ( - node, - node.length + (leftOffset ?? 0) + (rightOffset ?? 0), - node.height + (leftHeight ?? 0) + (rightHeight ?? 0), - 1 + leftCount + rightCount - ) - } -} - -private extension TextLineStorage { - // MARK: - Search - - /// Searches for the given offset. - /// - Parameter offset: The offset to look for in the document. - /// - Returns: A tuple containing a node if it was found, and the offset of the node in the document. - func search(for offset: Int) -> NodePosition? { - var currentNode = root - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 - var currentIndex: Int = root?.leftSubtreeCount ?? 0 - while let node = currentNode { - // If index is in the range [currentOffset..= currentOffset && offset < currentOffset + node.length) { - return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset, index: currentIndex) - } else if currentOffset > offset { - currentNode = node.left - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) - currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) - currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) - } else if node.leftSubtreeOffset < offset { - currentNode = node.right - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) - currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) - currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) - } else { - currentNode = nil - } - } - return nil - } - - /// Searches for the given index. - /// - Parameter index: The index to look for in the document. - /// - Returns: A tuple containing a node if it was found, and the offset of the node in the document. - func search(forIndex index: Int) -> NodePosition? { - var currentNode = root - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 - var currentIndex: Int = root?.leftSubtreeCount ?? 0 - while let node = currentNode { - if index == currentIndex { - return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset, index: currentIndex) - } else if currentIndex > index { - currentNode = node.left - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) - currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) - currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) - } else { - currentNode = node.right - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) - currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) - currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) - } - } - return nil - } - - // MARK: - Delete - - /// A basic RB-Tree node removal with specialization for node metadata. - /// - Parameter nodeZ: The node to remove. - func deleteNode(_ nodeZ: Node) { - metaFixup(startingAt: nodeZ, delta: -nodeZ.length, deltaHeight: -nodeZ.height, nodeAction: .deleted) - - var nodeY = nodeZ - var nodeX: Node? - var originalColor = nodeY.color - - if nodeZ.left == nil || nodeZ.right == nil { - nodeX = nodeZ.right ?? nodeZ.left - transplant(nodeZ, with: nodeX) - } else { - nodeY = nodeZ.right!.minimum() - - // Delete nodeY from it's original place in the tree. - metaFixup(startingAt: nodeY, delta: -nodeY.length, deltaHeight: -nodeY.height, nodeAction: .deleted) - - originalColor = nodeY.color - nodeX = nodeY.right - if nodeY.parent === nodeZ { - nodeX?.parent = nodeY - } else { - transplant(nodeY, with: nodeY.right) - - nodeY.right?.leftSubtreeCount = nodeY.leftSubtreeCount - nodeY.right?.leftSubtreeHeight = nodeY.leftSubtreeHeight - nodeY.right?.leftSubtreeOffset = nodeY.leftSubtreeOffset - - nodeY.right = nodeZ.right - nodeY.right?.parent = nodeY - } - transplant(nodeZ, with: nodeY) - nodeY.left = nodeZ.left - nodeY.left?.parent = nodeY - nodeY.color = nodeZ.color - nodeY.leftSubtreeCount = nodeZ.leftSubtreeCount - nodeY.leftSubtreeHeight = nodeZ.leftSubtreeHeight - nodeY.leftSubtreeOffset = nodeZ.leftSubtreeOffset - - // We've inserted nodeY again into a new spot. Update tree meta - metaFixup(startingAt: nodeY, delta: nodeY.length, deltaHeight: nodeY.height, nodeAction: .inserted) - } - - if originalColor == .black, let nodeX { - deleteFixup(node: nodeX) - } - } - - // MARK: - Fixup - - func insertFixup(node: Node) { - var nextNode: Node? = node - while var nodeX = nextNode, nodeX !== root, let nodeXParent = nodeX.parent, nodeXParent.color == .red { - let nodeY = nodeXParent.sibling() - if isLeftChild(nodeXParent) { - if nodeY?.color == .red { - nodeXParent.color = .black - nodeY?.color = .black - nodeX.parent?.parent?.color = .red - nextNode = nodeX.parent?.parent - } else { - if isRightChild(nodeX) { - nodeX = nodeXParent - leftRotate(node: nodeX) - } - - nodeX.parent?.color = .black - nodeX.parent?.parent?.color = .red - if let grandparent = nodeX.parent?.parent { - rightRotate(node: grandparent) - } - } - } else { - if nodeY?.color == .red { - nodeXParent.color = .black - nodeY?.color = .black - nodeX.parent?.parent?.color = .red - nextNode = nodeX.parent?.parent - } else { - if isLeftChild(nodeX) { - nodeX = nodeXParent - rightRotate(node: nodeX) - } - - nodeX.parent?.color = .black - nodeX.parent?.parent?.color = .red - if let grandparent = nodeX.parent?.parent { - leftRotate(node: grandparent) - } - } - } - } - - root?.color = .black - } - - func deleteFixup(node: Node) { - var nodeX: Node? = node - while let node = nodeX, node !== root, node.color == .black { - var sibling = node.sibling() - if sibling?.color == .red { - sibling?.color = .black - node.parent?.color = .red - if isLeftChild(node) { - leftRotate(node: node) - } else { - rightRotate(node: node) - } - sibling = node.sibling() - } - - if sibling?.left?.color == .black && sibling?.right?.color == .black { - sibling?.color = .red - nodeX = node.parent - } else { - if isLeftChild(node) { - if sibling?.right?.color == .black { - sibling?.left?.color = .black - sibling?.color = .red - if let sibling { - rightRotate(node: sibling) - } - sibling = node.parent?.right - } - sibling?.color = node.parent?.color ?? .black - node.parent?.color = .black - sibling?.right?.color = .black - leftRotate(node: node) - nodeX = root - } else { - if sibling?.left?.color == .black { - sibling?.left?.color = .black - sibling?.color = .red - if let sibling { - leftRotate(node: sibling) - } - sibling = node.parent?.left - } - sibling?.color = node.parent?.color ?? .black - node.parent?.color = .black - sibling?.left?.color = .black - rightRotate(node: node) - nodeX = root - } - } - } - nodeX?.color = .black - } - - /// Walk up the tree, updating any `leftSubtree` metadata. - private func metaFixup( - startingAt node: borrowing Node, - delta: Int, - deltaHeight: CGFloat, - nodeAction: MetaFixupAction = .none - ) { - guard node.parent != nil, root != nil else { return } - let rootRef = Unmanaged>.passUnretained(root!) - var ref = Unmanaged>.passUnretained(node) - while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), - ref.takeUnretainedValue() !== rootRef.takeUnretainedValue() { - if node.left === ref.takeUnretainedValue() { - node.leftSubtreeOffset += delta - node.leftSubtreeHeight += deltaHeight - switch nodeAction { - case .inserted: - node.leftSubtreeCount += 1 - case .deleted: - node.leftSubtreeCount -= 1 - case .none: - break - } - } - if node.parent != nil { - ref = Unmanaged.passUnretained(node) - } else { - return - } - } - } -} - -// MARK: - Rotations - -private extension TextLineStorage { - func rightRotate(node: Node) { - rotate(node: node, left: false) - } - - func leftRotate(node: Node) { - rotate(node: node, left: true) - } - - func rotate(node: Node, left: Bool) { - var nodeY: Node? - - if left { - nodeY = node.right - guard nodeY != nil else { return } - nodeY?.leftSubtreeOffset += node.leftSubtreeOffset + node.length - nodeY?.leftSubtreeHeight += node.leftSubtreeHeight + node.height - nodeY?.leftSubtreeCount += node.leftSubtreeCount + 1 - node.right = nodeY?.left - node.right?.parent = node - } else { - nodeY = node.left - guard nodeY != nil else { return } - node.left = nodeY?.right - node.left?.parent = node - } - - nodeY?.parent = node.parent - if node.parent == nil { - if let node = nodeY { - root = node - } - } else if isLeftChild(node) { - node.parent?.left = nodeY - } else if isRightChild(node) { - node.parent?.right = nodeY - } - - if left { - nodeY?.left = node - } else { - nodeY?.right = node - let metadata = getSubtreeMeta(startingAt: node.left) - node.leftSubtreeOffset = metadata.offset - node.leftSubtreeHeight = metadata.height - node.leftSubtreeCount = metadata.count - } - node.parent = nodeY - } - - /// Finds the correct subtree metadata starting at a node. - /// - Complexity: `O(log n)` where `n` is the number of nodes in the tree. - /// - Parameter node: The node to start finding metadata for. - /// - Returns: The metadata representing the entire subtree including `node`. - func getSubtreeMeta(startingAt node: Node?) -> NodeSubtreeMetadata { - guard let node else { return .zero } - return NodeSubtreeMetadata( - height: node.height + node.leftSubtreeHeight, - offset: node.length + node.leftSubtreeOffset, - count: 1 + node.leftSubtreeCount - ) + getSubtreeMeta(startingAt: node.right) - } -} - -// swiftlint:enable file_length diff --git a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift deleted file mode 100644 index a519939b7..000000000 --- a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// CursorView.swift -// -// -// Created by Khan Winter on 8/15/23. -// - -import AppKit - -/// Animates a cursor. Will sync animation with any other cursor views. -open class CursorView: NSView { - /// Used to sync the cursor view animations when there's multiple cursors. - /// - Note: Do not use any methods in this class from a non-main thread. - private class CursorTimerService { - static let notification: NSNotification.Name = .init("com.CodeEdit.CursorTimerService.notification") - var timer: Timer? - var isHidden: Bool = false - var listeners: Int = 0 - - func setUpTimer(blinkDuration: TimeInterval?) { - assertMain() - timer?.invalidate() - timer = nil - isHidden = false - NotificationCenter.default.post(name: Self.notification, object: nil) - if let blinkDuration { - timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in - self?.timerReceived() - }) - } - listeners += 1 - } - - func timerReceived() { - assertMain() - isHidden.toggle() - NotificationCenter.default.post(name: Self.notification, object: nil) - } - - func destroySharedTimer() { - assertMain() - listeners -= 1 - if listeners == 0 { - timer?.invalidate() - timer = nil - isHidden = false - } - } - - private func assertMain() { -#if DEBUG - // swiftlint:disable:next line_length - assert(Thread.isMainThread, "CursorTimerService used from non-main thread. This may cause a race condition.") -#endif - } - } - - /// The shared timer service - private static let timerService: CursorTimerService = CursorTimerService() - - /// The color of the cursor. - public var color: NSColor { - didSet { - layer?.backgroundColor = color.cgColor - } - } - - /// How often the cursor toggles it's visibility. Leave `nil` to never blink. - private let blinkDuration: TimeInterval? - /// The width of the cursor. - private let width: CGFloat - /// The timer observer. - private var observer: NSObjectProtocol? - - open override var isFlipped: Bool { - true - } - - /// Create a cursor view. - /// - Parameters: - /// - blinkDuration: The duration to blink, leave as nil to never blink. - /// - color: The color of the cursor. - /// - width: How wide the cursor should be. - init( - blinkDuration: TimeInterval? = 0.5, - color: NSColor = NSColor.labelColor, - width: CGFloat = 1.0 - ) { - self.blinkDuration = blinkDuration - self.color = color - self.width = width - - super.init(frame: .zero) - - frame.size.width = width - wantsLayer = true - layer?.backgroundColor = color.cgColor - - CursorView.timerService.setUpTimer(blinkDuration: blinkDuration) - - observer = NotificationCenter.default.addObserver( - forName: CursorTimerService.notification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.isHidden = CursorView.timerService.isHidden - } - } - - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - self.observer = nil - CursorView.timerService.destroySharedTimer() - } -} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift deleted file mode 100644 index d4c45d53f..000000000 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// TextSelectionManager+FillRects.swift -// -// -// Created by Khan Winter on 10/22/23. -// - -import Foundation - -extension TextSelectionManager { - /// Calculate a set of rects for a text selection suitable for highlighting the selection. - /// - Parameters: - /// - rect: The bounding rect of available draw space. - /// - textSelection: The selection to use. - /// - Returns: An array of rects that the selection overlaps. - func getFillRects(in rect: NSRect, for textSelection: TextSelection) -> [CGRect] { - guard let layoutManager else { return [] } - let range = textSelection.range - - var fillRects: [CGRect] = [] - guard let firstLinePosition = layoutManager.lineStorage.getLine(atOffset: range.location), - let lastLinePosition = range.max == layoutManager.lineStorage.length - ? layoutManager.lineStorage.last - : layoutManager.lineStorage.getLine(atOffset: range.max) else { - return [] - } - - // Calculate the first line and any rects selected - // If the last line position is not the same as the first, calculate any rects from that line. - // If there's > 0 space between the first and last positions, add a rect between them to cover any - // intermediate lines. - - fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: firstLinePosition)) - - if lastLinePosition.range != firstLinePosition.range { - fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: lastLinePosition)) - } - - if firstLinePosition.yPos + firstLinePosition.height < lastLinePosition.yPos { - fillRects.append(CGRect( - x: rect.minX, - y: firstLinePosition.yPos + firstLinePosition.height, - width: rect.width, - height: lastLinePosition.yPos - (firstLinePosition.yPos + firstLinePosition.height) - )) - } - - return fillRects - } - - /// Find fill rects for a specific line position. - /// - Parameters: - /// - rect: The bounding rect of the overall view. - /// - range: The selected range to create fill rects for. - /// - linePosition: The line position to use. - /// - Returns: An array of rects that the selection overlaps. - private func getFillRects( - in rect: NSRect, - selectionRange range: NSRange, - forPosition linePosition: TextLineStorage.TextLinePosition - ) -> [CGRect] { - guard let layoutManager else { return [] } - var fillRects: [CGRect] = [] - - // The selected range contains some portion of the line - for fragmentPosition in linePosition.data.lineFragments { - guard let fragmentRange = fragmentPosition - .range - .shifted(by: linePosition.range.location), - let intersectionRange = fragmentRange.intersection(range), - let minRect = layoutManager.rectForOffset(intersectionRange.location) else { - continue - } - - let maxRect: CGRect - // 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 { - maxRect = CGRect( - x: rect.maxX, - y: fragmentPosition.yPos + linePosition.yPos, - width: 0, - height: fragmentPosition.height - ) - } else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) { - maxRect = maxFragmentRect - } else { - continue - } - - fillRects.append(CGRect( - x: minRect.origin.x, - y: minRect.origin.y, - width: maxRect.minX - minRect.minX, - height: max(minRect.height, maxRect.height) - )) - } - - return fillRects - } -} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift deleted file mode 100644 index e917f1730..000000000 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// TextSelectionManager+Move.swift -// -// -// Created by Khan Winter on 9/20/23. -// - -import AppKit - -extension TextSelectionManager { - /// Moves all selections, determined by the direction and destination provided. - /// - /// Also handles updating the selection views and marks the view as needing display. - /// - /// - Parameters: - /// - direction: The direction to modify all selections. - /// - destination: The destination to move the selections by. - /// - modifySelection: Set to `true` to modify the selections instead of replacing it. - public func moveSelections( - direction: TextSelectionManager.Direction, - destination: TextSelectionManager.Destination, - modifySelection: Bool = false - ) { - textSelections.forEach { - moveSelection( - selection: $0, - direction: direction, - destination: destination, - modifySelection: modifySelection - ) - } - updateSelectionViews() - delegate?.setNeedsDisplay() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } - - /// Moves a single selection determined by the direction and destination provided. - /// - Parameters: - /// - selection: The selection to modify. - /// - direction: The direction to move in. - /// - destination: The destination of the move. - /// - modifySelection: Set to `true` to modify the selection instead of replacing it. - private func moveSelection( - selection: TextSelectionManager.TextSelection, - direction: TextSelectionManager.Direction, - destination: TextSelectionManager.Destination, - modifySelection: Bool = false - ) { - if !selection.range.isEmpty - && !modifySelection - && (direction == .backward || direction == .forward) - && destination == .character { - if direction == .forward { - selection.range.location = selection.range.max - } - selection.range.length = 0 - return - } - - // Update pivot if necessary - if modifySelection { - updateSelectionPivot(selection, direction: direction) - } - - // Find where to modify the selection from. - let startLocation = findSelectionStartLocation( - selection, - direction: direction, - modifySelection: modifySelection - ) - - let range = rangeOfSelection( - from: startLocation, - direction: direction, - destination: destination, - suggestedXPos: selection.suggestedXPos - ) - - // Update the suggested x position - updateSelectionXPos(selection, newRange: range, direction: direction, destination: destination) - - // Update the selection range - updateSelectionRange( - selection, - newRange: range, - modifySelection: modifySelection, - direction: direction, - destination: destination - ) - } - - private func findSelectionStartLocation( - _ selection: TextSelectionManager.TextSelection, - direction: TextSelectionManager.Direction, - modifySelection: Bool - ) -> Int { - if modifySelection { - guard let pivot = selection.pivot else { - assertionFailure("Pivot should always exist when modifying a selection.") - return 0 - } - switch direction { - case .up, .forward: - if pivot > selection.range.location { - return selection.range.location - } else { - return selection.range.max - } - case .down, .backward: - if pivot < selection.range.max { - return selection.range.max - } else { - return selection.range.location - } - } - } else { - if direction == .forward || (direction == .down && !selection.range.isEmpty) { - return selection.range.max - } else { - return selection.range.location - } - } - } - - private func updateSelectionPivot( - _ selection: TextSelectionManager.TextSelection, - direction: TextSelectionManager.Direction - ) { - guard selection.pivot == nil else { return } - switch direction { - case .up: - selection.pivot = selection.range.max - case .down: - selection.pivot = selection.range.location - case .forward: - selection.pivot = selection.range.location - case .backward: - selection.pivot = selection.range.max - } - } - - private func updateSelectionXPos( - _ selection: TextSelectionManager.TextSelection, - newRange range: NSRange, - direction: TextSelectionManager.Direction, - destination: TextSelectionManager.Destination - ) { - switch direction { - case .up: - if destination != .line { - selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.location)?.minX - } else { - selection.suggestedXPos = nil - } - case .down: - if destination == .line { - selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX - } else { - selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.max)?.minX - } - case .forward: - selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX - case .backward: - selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX - } - } - - private func updateSelectionRange( - _ selection: TextSelectionManager.TextSelection, - newRange range: NSRange, - modifySelection: Bool, - direction: TextSelectionManager.Direction, - destination: TextSelectionManager.Destination - ) { - if modifySelection { - guard let pivot = selection.pivot else { - assertionFailure("Pivot should always exist when modifying a selection.") - return - } - switch direction { - case .down, .forward: - if range.contains(pivot) { - selection.range.location = pivot - selection.range.length = range.length - (pivot - range.location) - } else if pivot > selection.range.location { - selection.range.location += range.length - selection.range.length -= range.length - } else { - selection.range.formUnion(range) - } - case .up, .backward: - if range.contains(pivot) { - selection.range.location = range.location - selection.range.length = pivot - range.location - } else if pivot < selection.range.max { - selection.range.length -= range.length - } else { - selection.range.formUnion(range) - } - } - } else { - switch direction { - case .up, .backward: - selection.range = NSRange(location: range.location, length: 0) - selection.pivot = range.location - case .down, .forward: - selection.range = NSRange(location: range.max, length: 0) - selection.pivot = range.max - } - } - } -} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift deleted file mode 100644 index d8c2c9ee6..000000000 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ /dev/null @@ -1,399 +0,0 @@ -// -// TextSelectionManager+SelectionManipulation.swift -// -// -// Created by Khan Winter on 8/26/23. -// - -import AppKit - -public extension TextSelectionManager { - // MARK: - Range Of Selection - - /// Creates a range for a new selection given a starting point, direction, and destination. - /// - Parameters: - /// - offset: The location to start the selection from. - /// - direction: The direction the selection should be created in. - /// - destination: Determines how far the selection is. - /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: A range of a new selection based on the direction and destination. - func rangeOfSelection( - from offset: Int, - direction: Direction, - destination: Destination, - decomposeCharacters: Bool = false, - suggestedXPos: CGFloat? = nil - ) -> NSRange { - switch direction { - case .backward: - guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0 - return extendSelection( - from: offset, - destination: destination, - delta: -1, - decomposeCharacters: decomposeCharacters - ) - case .forward: - return extendSelection( - from: offset, - destination: destination, - delta: 1, - decomposeCharacters: decomposeCharacters - ) - case .up: - return extendSelectionVertical( - from: offset, - destination: destination, - up: true, - suggestedXPos: suggestedXPos - ) - case .down: - return extendSelectionVertical( - from: offset, - destination: destination, - up: false, - suggestedXPos: suggestedXPos - ) - } - } - - /// Extends a selection from the given offset determining the length by the destination. - /// - /// Returns a new range that needs to be merged with an existing selection range using `NSRange.formUnion` - /// - /// - Parameters: - /// - offset: The location to start extending the selection from. - /// - destination: Determines how far the selection is extended. - /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. - /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters. - /// - Returns: A new range to merge with a selection. - private func extendSelection( - from offset: Int, - destination: Destination, - delta: Int, - decomposeCharacters: Bool = false - ) -> NSRange { - guard let string = textStorage?.string as NSString? else { return NSRange(location: offset, length: 0) } - - switch destination { - case .character: - return extendSelectionCharacter( - string: string, - from: offset, - delta: delta, - decomposeCharacters: decomposeCharacters - ) - case .word: - return extendSelectionWord(string: string, from: offset, delta: delta) - case .line, .container: - return extendSelectionLine(string: string, from: offset, delta: delta) - case .visualLine: - return extendSelectionVisualLine(string: string, from: offset, delta: delta) - case .document: - if delta > 0 { - return NSRange(location: offset, length: string.length - offset) - } else { - return NSRange(location: 0, length: offset) - } - } - } - - // MARK: - Horizontal Methods - - /// Extends the selection by a single character. - /// - /// The range returned from this method can be longer than `1` character if the character in the extended direction - /// is a member of a grapheme cluster. - /// - /// - Parameters: - /// - string: The reference string to use. - /// - offset: The location to start extending the selection from. - /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. - /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters. - /// - Returns: The range of the extended selection. - private func extendSelectionCharacter( - string: NSString, - from offset: Int, - delta: Int, - decomposeCharacters: Bool - ) -> NSRange { - let range = delta > 0 ? NSRange(location: offset, length: 1) : NSRange(location: offset - 1, length: 1) - if delta > 0 && offset == string.length { - return NSRange(location: offset, length: 0) - } else if delta < 0 && offset == 0 { - return NSRange(location: 0, length: 0) - } - - return decomposeCharacters ? range : string.rangeOfComposedCharacterSequences(for: range) - } - - /// Extends the selection by one "word". - /// - /// Words in this case begin after encountering an alphanumeric character, and extend until either a whitespace - /// or punctuation character. - /// - /// - Parameters: - /// - string: The reference string to use. - /// - offset: The location to start extending the selection from. - /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. - /// - Returns: The range of the extended selection. - private func extendSelectionWord(string: NSString, from offset: Int, delta: Int) -> NSRange { - var enumerationOptions: NSString.EnumerationOptions = .byCaretPositions - if delta < 0 { - enumerationOptions.formUnion(.reverse) - } - var rangeToDelete = NSRange(location: offset, length: 0) - - var hasFoundValidWordChar = false - string.enumerateSubstrings( - in: NSRange(location: delta > 0 ? offset : 0, length: delta > 0 ? string.length - offset : offset), - options: enumerationOptions - ) { substring, _, _, stop in - guard let substring = substring else { - stop.pointee = true - return - } - - if hasFoundValidWordChar && CharacterSet.punctuationCharacters - .union(.whitespacesAndNewlines) - .isSuperset(of: CharacterSet(charactersIn: substring)) { - stop.pointee = true - return - } else if CharacterSet.alphanumerics.isSuperset(of: CharacterSet(charactersIn: substring)) { - hasFoundValidWordChar = true - } - rangeToDelete.length += substring.count - - if delta < 0 { - rangeToDelete.location -= substring.count - } - } - - return rangeToDelete - } - - /// Extends the selection by one visual line in the direction specified (eg one line fragment). - /// - /// If extending backwards, this method will return the beginning of the leading non-whitespace characters - /// in the line. If the offset is located in the leading whitespace it will return the real line beginning. - /// For Example - /// ``` - /// ^ = offset, ^--^ = returned range - /// Line: - /// Loren Ipsum - /// ^ - /// Extend 1st Call: - /// Loren Ipsum - /// ^-----^ - /// Extend 2nd Call: - /// Loren Ipsum - /// ^----^ - /// ``` - /// - /// - Parameters: - /// - string: The reference string to use. - /// - offset: The location to start extending the selection from. - /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. - /// - Returns: The range of the extended selection. - private func extendSelectionVisualLine(string: NSString, from offset: Int, delta: Int) -> NSRange { - guard let line = layoutManager?.textLineForOffset(offset), - let lineFragment = line.data.typesetter.lineFragments.getLine(atOffset: offset - line.range.location) - else { - return NSRange(location: offset, length: 0) - } - let lineBound = delta > 0 - ? line.range.location + min( - lineFragment.range.max, - line.range.max - line.range.location - (layoutManager?.detectedLineEnding.length ?? 1) - ) - : line.range.location + lineFragment.range.location - - return _extendSelectionLine(string: string, lineBound: lineBound, offset: offset, delta: delta) - } - - /// Extends the selection by one real line in the direction specified. - /// - /// If extending backwards, this method will return the beginning of the leading non-whitespace characters - /// in the line. If the offset is located in the leading whitespace it will return the real line beginning. - /// - /// - Parameters: - /// - string: The reference string to use. - /// - offset: The location to start extending the selection from. - /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. - /// - Returns: The range of the extended selection. - private func extendSelectionLine(string: NSString, from offset: Int, delta: Int) -> NSRange { - guard let line = layoutManager?.textLineForOffset(offset), - let lineText = textStorage?.substring(from: line.range) else { - return NSRange(location: offset, length: 0) - } - let lineBound = delta > 0 - ? line.range.max - (LineEnding(line: lineText)?.length ?? 0) - : line.range.location - - return _extendSelectionLine(string: string, lineBound: lineBound, offset: offset, delta: delta) - } - - /// Common code for `extendSelectionLine` and `extendSelectionVisualLine` - private func _extendSelectionLine( - string: NSString, - lineBound: Int, - offset: Int, - delta: Int - ) -> NSRange { - var foundRange = NSRange( - location: min(lineBound, offset), - length: max(lineBound, offset) - min(lineBound, offset) - ) - let originalFoundRange = foundRange - - // Only do this if we're going backwards. - if delta < 0 { - foundRange = findBeginningOfLineText(string: string, initialRange: foundRange) - } - - return foundRange.length == 0 ? originalFoundRange : foundRange - } - - /// Finds the beginning of text in a line not including whitespace. - /// - Parameters: - /// - string: The string to look in. - /// - initialRange: The range to begin looking from. - /// - Returns: A new range to replace the given range for the line. - private func findBeginningOfLineText(string: NSString, initialRange: NSRange) -> NSRange { - var foundRange = initialRange - string.enumerateSubstrings(in: foundRange, options: .byCaretPositions) { substring, _, _, stop in - if let substring = substring as String? { - if CharacterSet - .whitespacesAndNewlines.subtracting(.newlines) - .isSuperset(of: CharacterSet(charactersIn: substring)) { - foundRange.location += 1 - foundRange.length -= 1 - } else { - stop.pointee = true - } - } else { - stop.pointee = true - } - } - return foundRange - } - - // MARK: - Vertical Methods - - /// Extends a selection from the given offset vertically to the destination. - /// - Parameters: - /// - offset: The offset to extend from. - /// - destination: The destination to extend to. - /// - up: Set to true if extending up. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: The range of the extended selection. - private func extendSelectionVertical( - from offset: Int, - destination: Destination, - up: Bool, - suggestedXPos: CGFloat? - ) -> NSRange { - switch destination { - case .character: - return extendSelectionVerticalCharacter(from: offset, up: up, suggestedXPos: suggestedXPos) - case .word, .line, .visualLine: - return extendSelectionVerticalLine(from: offset, up: up) - case .container: - return extendSelectionContainer(from: offset, delta: up ? 1 : -1) - case .document: - if up { - return NSRange(location: 0, length: offset) - } else { - return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset) - } - } - } - - /// Extends the selection to the nearest character vertically. - /// - Parameters: - /// - offset: The offset to extend from. - /// - up: Set to true if extending up. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: The range of the extended selection. - private func extendSelectionVerticalCharacter( - from offset: Int, - up: Bool, - suggestedXPos: CGFloat? - ) -> NSRange { - guard let point = layoutManager?.rectForOffset(offset)?.origin, - let newOffset = layoutManager?.textOffsetAtPoint( - CGPoint( - x: suggestedXPos == nil ? point.x : suggestedXPos!, - y: point.y - (layoutManager?.estimateLineHeight() ?? 2.0)/2 * (up ? 1 : -3) - ) - ) else { - return NSRange(location: offset, length: 0) - } - - return NSRange( - location: up ? newOffset : offset, - length: up ? offset - newOffset : newOffset - offset - ) - } - - /// Extends the selection to the nearest line vertically. - /// - /// If moving up and the offset is in the middle of the line, it first extends it to the beginning of the line. - /// On the second call, it will extend it to the beginning of the previous line. When moving down, the - /// same thing will happen in the opposite direction. - /// - /// - Parameters: - /// - offset: The offset to extend from. - /// - up: Set to true if extending up. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: The range of the extended selection. - private func extendSelectionVerticalLine( - from offset: Int, - up: Bool - ) -> NSRange { - // Important distinction here, when moving up/down on a line and in the middle of the line, we move to the - // beginning/end of the *entire* line, not the line fragment. - guard let line = layoutManager?.textLineForOffset(offset) else { - return NSRange(location: offset, length: 0) - } - if up && line.range.location != offset { - return NSRange(location: line.range.location, length: offset - line.index) - } else if !up && line.range.max - (layoutManager?.detectedLineEnding.length ?? 0) != offset { - return NSRange( - location: offset, - length: line.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) - ) - } else { - let nextQueryIndex = up ? max(line.range.location - 1, 0) : min(line.range.max, (textStorage?.length ?? 0)) - guard let nextLine = layoutManager?.textLineForOffset(nextQueryIndex) else { - return NSRange(location: offset, length: 0) - } - return NSRange( - location: up ? nextLine.range.location : offset, - length: up - ? offset - nextLine.range.location - : nextLine.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) - ) - } - } - - /// Extends a selection one "container" long. - /// - Parameters: - /// - offset: The location to start extending the selection from. - /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. - /// - Returns: The range of the extended selection. - private func extendSelectionContainer(from offset: Int, delta: Int) -> NSRange { - guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint( - CGPoint( - x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX, - y: delta > 0 ? layoutView.frame.maxY : layoutView.frame.minY - ) - ) else { - return NSRange(location: offset, length: 0) - } - return endOffset > offset - ? NSRange(location: offset, length: endOffset - offset) - : NSRange(location: endOffset, length: offset - endOffset) - } -} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift deleted file mode 100644 index f0b1fffe4..000000000 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// TextSelectionManager+Update.swift -// -// -// Created by Khan Winter on 10/22/23. -// - -import Foundation - -extension TextSelectionManager { - public func didReplaceCharacters(in range: NSRange, replacementLength: Int) { - let delta = replacementLength == 0 ? -range.length : replacementLength - for textSelection in self.textSelections { - if textSelection.range.location > range.max { - textSelection.range.location = max(0, textSelection.range.location + delta) - textSelection.range.length = 0 - } else if textSelection.range.intersection(range) != nil - || textSelection.range == range - || (textSelection.range.isEmpty && textSelection.range.location == range.max) { - if replacementLength > 0 { - textSelection.range.location = range.location + replacementLength - } else { - textSelection.range.location = range.location - } - textSelection.range.length = 0 - } else { - textSelection.range.length = 0 - } - } - - // Clean up duplicate selection ranges - var allRanges: Set = [] - for (idx, selection) in self.textSelections.enumerated().reversed() { - if allRanges.contains(selection.range) { - self.textSelections.remove(at: idx) - } else { - allRanges.insert(selection.range) - } - } - } - - func notifyAfterEdit() { - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } -} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift deleted file mode 100644 index 8f766ff8d..000000000 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// TextSelectionManager.swift -// -// -// Created by Khan Winter on 7/17/23. -// - -import AppKit - -public protocol TextSelectionManagerDelegate: AnyObject { - var visibleTextRange: NSRange? { get } - - func setNeedsDisplay() - func estimatedLineHeight() -> CGFloat -} - -/// Manages an array of text selections representing cursors (0-length ranges) and selections (>0-length ranges). -/// -/// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds cursor views when -/// appropriate. -public class TextSelectionManager: NSObject { - // MARK: - TextSelection - - public class TextSelection: Hashable, Equatable { - public var range: NSRange - weak var view: CursorView? - var boundingRect: CGRect = .zero - var suggestedXPos: CGFloat? - /// The position this selection should 'rotate' around when modifying selections. - var pivot: Int? - - init(range: NSRange, view: CursorView? = nil) { - self.range = range - self.view = view - } - - var isCursor: Bool { - range.length == 0 - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(range) - } - - public static func == (lhs: TextSelection, rhs: TextSelection) -> Bool { - lhs.range == rhs.range - } - } - - public enum Destination { - case character - case word - case line - case visualLine - /// Eg: Bottom of screen - case container - case document - } - - public enum Direction { - case up - case down - case forward - case backward - } - - // MARK: - Properties - - // swiftlint:disable:next line_length - public static let selectionChangedNotification: Notification.Name = Notification.Name("com.CodeEdit.TextSelectionManager.TextSelectionChangedNotification") - - public var insertionPointColor: NSColor = NSColor.labelColor { - didSet { - textSelections.forEach { $0.view?.color = insertionPointColor } - } - } - public var highlightSelectedLine: Bool = true - public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor - - internal(set) public var textSelections: [TextSelection] = [] - weak var layoutManager: TextLayoutManager? - weak var textStorage: NSTextStorage? - weak var layoutView: NSView? - weak var delegate: TextSelectionManagerDelegate? - - init( - layoutManager: TextLayoutManager, - textStorage: NSTextStorage, - layoutView: NSView?, - delegate: TextSelectionManagerDelegate? - ) { - self.layoutManager = layoutManager - self.textStorage = textStorage - self.layoutView = layoutView - self.delegate = delegate - super.init() - textSelections = [] - updateSelectionViews() - } - - // MARK: - Selected Ranges - - public func setSelectedRange(_ range: NSRange) { - textSelections.forEach { $0.view?.removeFromSuperview() } - let selection = TextSelection(range: range) - selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX - textSelections = [selection] - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } - - public func setSelectedRanges(_ ranges: [NSRange]) { - textSelections.forEach { $0.view?.removeFromSuperview() } - // Remove duplicates, invalid ranges, update suggested X position. - textSelections = Set(ranges) - .filter { - (0...(textStorage?.length ?? 0)).contains($0.location) - && (0...(textStorage?.length ?? 0)).contains($0.max) - } - .map { - let selection = TextSelection(range: $0) - selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX - return selection - } - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } - - public func addSelectedRange(_ range: NSRange) { - let newTextSelection = TextSelection(range: range) - var didHandle = false - for textSelection in textSelections { - if textSelection.range == newTextSelection.range { - // Duplicate range, ignore - return - } else if (range.length > 0 && textSelection.range.intersection(range) != nil) - || textSelection.range.max == range.location { - // Range intersects existing range, modify this range to be the union of both and don't add the new - // selection - textSelection.range = textSelection.range.union(range) - didHandle = true - } - } - if !didHandle { - textSelections.append(newTextSelection) - } - - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } - - // MARK: - Selection Views - - func updateSelectionViews() { - var didUpdate: Bool = false - - for textSelection in textSelections { - if textSelection.range.isEmpty { - let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin - if textSelection.view == nil - || textSelection.boundingRect.origin != cursorOrigin - || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 { - textSelection.view?.removeFromSuperview() - textSelection.view = nil - let cursorView = CursorView(color: insertionPointColor) - cursorView.frame.origin = cursorOrigin - cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0 - layoutView?.addSubview(cursorView) - textSelection.view = cursorView - textSelection.boundingRect = cursorView.frame - didUpdate = true - } - } else if !textSelection.range.isEmpty && textSelection.view != nil { - textSelection.view?.removeFromSuperview() - textSelection.view = nil - didUpdate = true - } - } - - if didUpdate { - delegate?.setNeedsDisplay() - } - } - - func removeCursors() { - for textSelection in textSelections { - textSelection.view?.removeFromSuperview() - } - } - - // MARK: - Draw - - /// 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) { - guard let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() - var highlightedLines: Set = [] - // For each selection in the rect - for textSelection in textSelections { - if textSelection.range.isEmpty { - drawHighlightedLine( - in: rect, - for: textSelection, - context: context, - highlightedLines: &highlightedLines - ) - } else { - drawSelectedRange(in: rect, for: textSelection, context: context) - } - } - context.restoreGState() - } - - /// Draws a highlighted line in the given rect. - /// - Parameters: - /// - rect: The rect to draw in. - /// - textSelection: The selection to draw. - /// - context: The context to draw in. - /// - highlightedLines: The set of all lines that have already been highlighted, used to avoid highlighting lines - /// twice and updated if this function comes across a new line id. - private func drawHighlightedLine( - in rect: NSRect, - for textSelection: TextSelection, - context: CGContext, - highlightedLines: inout Set - ) { - guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location), - !highlightedLines.contains(linePosition.data.id) else { - return - } - highlightedLines.insert(linePosition.data.id) - context.saveGState() - let selectionRect = CGRect( - x: rect.minX, - y: linePosition.yPos, - width: rect.width, - height: linePosition.height - ) - if selectionRect.intersects(rect) { - context.setFillColor(selectedLineBackgroundColor.cgColor) - context.fill(selectionRect) - } - context.restoreGState() - } - - /// Draws a selected range in the given context. - /// - Parameters: - /// - rect: The rect to draw in. - /// - range: The range to highlight. - /// - context: The context to draw in. - private func drawSelectedRange(in rect: NSRect, for textSelection: TextSelection, context: CGContext) { - context.saveGState() - context.setFillColor(selectionBackgroundColor.cgColor) - - let fillRects = getFillRects(in: rect, for: textSelection) - - let min = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin ?? .zero - let max = fillRects.max(by: { $0.origin.y < $1.origin.y }) ?? .zero - let size = CGSize(width: max.maxX - min.x, height: max.maxY - min.y) - textSelection.boundingRect = CGRect(origin: min, size: size) - - context.fill(fillRects) - context.restoreGState() - } -} - -// MARK: - Private TextSelection - -private extension TextSelectionManager.TextSelection { - func didInsertText(length: Int, retainLength: Bool = false) { - if !retainLength { - range.length = 0 - } - range.location += length - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift b/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift deleted file mode 100644 index aa4fe3153..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// TextView+Accessibility.swift -// -// -// Created by Khan Winter on 10/14/23. -// - -import AppKit - -/// # Notes -/// -/// 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. -extension TextView { - override open func isAccessibilityElement() -> Bool { - true - } - - override open func isAccessibilityEnabled() -> Bool { - true - } - - override open func isAccessibilityFocused() -> Bool { - isFirstResponder - } - - override open func accessibilityLabel() -> String? { - "Text Editor" - } - - override open func accessibilityRole() -> NSAccessibility.Role? { - .textArea - } - - override open func accessibilityValue() -> Any? { - string - } - - override open func setAccessibilityValue(_ accessibilityValue: Any?) { - guard let string = accessibilityValue as? String else { - return - } - - self.string = string - } - - override open func accessibilityString(for range: NSRange) -> String? { - textStorage.substring( - from: textStorage.mutableString.rangeOfComposedCharacterSequences(for: range) - ) - } - - // MARK: Selections - - override open func accessibilitySelectedText() -> String? { - guard let selection = selectionManager - .textSelections - .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) - .first else { - return nil - } - let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selection.range) - return textStorage.substring(from: range) - } - - override open func accessibilitySelectedTextRange() -> NSRange { - guard let selection = selectionManager - .textSelections - .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) - .first else { - return .zero - } - return textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range) - } - - override open func accessibilitySelectedTextRanges() -> [NSValue]? { - selectionManager.textSelections.map { selection in - textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range) as NSValue - } - } - - 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 - } - return linePosition.index - } - - override open func setAccessibilitySelectedTextRange(_ accessibilitySelectedTextRange: NSRange) { - selectionManager.setSelectedRange(accessibilitySelectedTextRange) - } - - override open func setAccessibilitySelectedTextRanges(_ accessibilitySelectedTextRanges: [NSValue]?) { - let ranges = accessibilitySelectedTextRanges?.compactMap { $0 as? NSRange } ?? [] - selectionManager.setSelectedRanges(ranges) - } - - // MARK: Text Ranges - - override open func accessibilityNumberOfCharacters() -> Int { - string.count - } - - override open func accessibilityRange(forLine line: Int) -> NSRange { - guard line >= 0 && layoutManager.lineStorage.count > line, - let linePosition = layoutManager.textLineForIndex(line) else { - return .zero - } - return linePosition.range - } - - override open func accessibilityRange(for point: NSPoint) -> NSRange { - guard let location = layoutManager.textOffsetAtPoint(point) else { return .zero } - return NSRange(location: location, length: 0) - } - - override open func accessibilityRange(for index: Int) -> NSRange { - textStorage.mutableString.rangeOfComposedCharacterSequence(at: index) - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift b/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift deleted file mode 100644 index b89c25c2e..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// TextView+CopyPaste.swift -// -// -// Created by Khan Winter on 8/21/23. -// - -import AppKit - -extension TextView { - @objc open func copy(_ sender: AnyObject) { - guard let textSelections = selectionManager? - .textSelections - .compactMap({ textStorage.attributedSubstring(from: $0.range) }), - !textSelections.isEmpty else { - return - } - NSPasteboard.general.clearContents() - NSPasteboard.general.writeObjects(textSelections) - } - - @objc open func paste(_ sender: AnyObject) { - guard let stringContents = NSPasteboard.general.string(forType: .string) else { return } - insertText(stringContents, replacementRange: NSRange(location: NSNotFound, length: 0)) - } - - @objc open func cut(_ sender: AnyObject) { - copy(sender) - deleteBackward(sender) - } - - @objc open func delete(_ sender: AnyObject) { - deleteBackward(sender) - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Delete.swift b/Sources/CodeEditInputView/TextView/TextView+Delete.swift deleted file mode 100644 index 6804a2375..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Delete.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// TextView+Delete.swift -// -// -// Created by Khan Winter on 8/24/23. -// - -import AppKit - -extension TextView { - open override func deleteBackward(_ sender: Any?) { - delete(direction: .backward, destination: .character) - } - - open override func deleteBackwardByDecomposingPreviousCharacter(_ sender: Any?) { - delete(direction: .backward, destination: .character, decomposeCharacters: true) - } - - open override func deleteForward(_ sender: Any?) { - delete(direction: .forward, destination: .character) - } - - open override func deleteWordBackward(_ sender: Any?) { - delete(direction: .backward, destination: .word) - } - - open override func deleteWordForward(_ sender: Any?) { - delete(direction: .forward, destination: .word) - } - - open override func deleteToBeginningOfLine(_ sender: Any?) { - delete(direction: .backward, destination: .line) - } - - open override func deleteToEndOfLine(_ sender: Any?) { - delete(direction: .forward, destination: .line) - } - - open override func deleteToBeginningOfParagraph(_ sender: Any?) { - delete(direction: .backward, destination: .line) - } - - open override func deleteToEndOfParagraph(_ sender: Any?) { - delete(direction: .forward, destination: .line) - } - - private func delete( - direction: TextSelectionManager.Direction, - destination: TextSelectionManager.Destination, - decomposeCharacters: Bool = false - ) { - /// Extend each selection by a distance specified by `destination`, then update both storage and the selection. - for textSelection in selectionManager.textSelections { - let extendedRange = selectionManager.rangeOfSelection( - from: textSelection.range.location, - direction: direction, - destination: destination - ) - guard extendedRange.location >= 0 else { continue } - textSelection.range.formUnion(extendedRange) - } - replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "") - unmarkTextIfNeeded() - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Drag.swift b/Sources/CodeEditInputView/TextView/TextView+Drag.swift deleted file mode 100644 index 171e0c4ab..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Drag.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// TextView+Drag.swift -// -// -// Created by Khan Winter on 10/20/23. -// - -import AppKit - -extension TextView: NSDraggingSource { - class DragSelectionGesture: NSPressGestureRecognizer { - override func mouseDown(with event: NSEvent) { - guard isEnabled, let view = self.view as? TextView, event.type == .leftMouseDown else { - return - } - - let clickPoint = view.convert(event.locationInWindow, from: nil) - let selectionRects = view.selectionManager.textSelections.filter({ !$0.range.isEmpty }).flatMap { - view.selectionManager.getFillRects(in: view.frame, for: $0) - } - if !selectionRects.contains(where: { $0.contains(clickPoint) }) { - state = .failed - } - - super.mouseDown(with: event) - } - } - - func setUpDragGesture() { - let dragGesture = DragSelectionGesture(target: self, action: #selector(dragGestureHandler(_:))) - dragGesture.minimumPressDuration = NSEvent.doubleClickInterval / 3 - dragGesture.isEnabled = isSelectable - 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 - ) - - guard let bitmap = bitmapImageRepForCachingDisplay(in: imageBounds) else { - return - } - - selectionRects.forEach { selectionRect in - self.cacheDisplay(in: selectionRect, to: bitmap) - } - - let draggingImage = NSImage(cgImage: bitmap.cgImage!, size: imageBounds.size) - - let attributedString = selectionManager - .textSelections - .sorted(by: { $0.range.location < $1.range.location }) - .map { textStorage.attributedSubstring(from: $0.range) } - .reduce(NSMutableAttributedString(), { $0.append($1); return $0 }) - let draggingItem = NSDraggingItem(pasteboardWriter: attributedString) - draggingItem.setDraggingFrame(imageBounds, contents: draggingImage) - - beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self) - } - - public func draggingSession( - _ session: NSDraggingSession, - sourceOperationMaskFor context: NSDraggingContext - ) -> NSDragOperation { - context == .outsideApplication ? .copy : .move - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Insert.swift b/Sources/CodeEditInputView/TextView/TextView+Insert.swift deleted file mode 100644 index 8d87288e0..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Insert.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// TextView+Insert.swift -// -// -// Created by Khan Winter on 9/3/23. -// - -import AppKit - -extension TextView { - override public func insertNewline(_ sender: Any?) { - insertText(layoutManager.detectedLineEnding.rawValue) - } - - override public func insertTab(_ sender: Any?) { - insertText("\t") - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Menu.swift b/Sources/CodeEditInputView/TextView/TextView+Menu.swift deleted file mode 100644 index ef55705f4..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Menu.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// TextView+Menu.swift -// -// -// Created by Khan Winter on 8/21/23. -// - -import AppKit - -extension TextView { - open override class var defaultMenu: NSMenu? { - let menu = NSMenu() - - menu.items = [ - NSMenuItem(title: "Copy", action: #selector(undo(_:)), keyEquivalent: "c"), - NSMenuItem(title: "Paste", action: #selector(undo(_:)), keyEquivalent: "v") - ] - - return menu - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift deleted file mode 100644 index 77fee3682..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// TextView+Mouse.swift -// -// -// Created by Khan Winter on 9/19/23. -// - -import AppKit - -extension TextView { - override public func mouseDown(with event: NSEvent) { - // Set cursor - guard isSelectable, - event.type == .leftMouseDown, - let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else { - super.mouseDown(with: event) - return - } - - switch event.clickCount { - case 1: - guard isEditable else { - super.mouseDown(with: event) - return - } - if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) { - unmarkText() - selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) - } else { - selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) - unmarkTextIfNeeded() - } - case 2: - unmarkText() - selectWord(nil) - case 3: - unmarkText() - selectLine(nil) - default: - 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) - } - } - - if !self.isFirstResponder { - self.window?.makeFirstResponder(self) - } - } - - override public func mouseUp(with event: NSEvent) { - mouseDragAnchor = nil - mouseDragTimer?.invalidate() - mouseDragTimer = nil - super.mouseUp(with: event) - } - - override public func mouseDragged(with event: NSEvent) { - guard !(inputContext?.handleEvent(event) ?? false) && isSelectable else { - return - } - - if mouseDragAnchor == nil { - mouseDragAnchor = convert(event.locationInWindow, from: nil) - super.mouseDragged(with: event) - } else { - guard let mouseDragAnchor, - let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor), - let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else { - return - } - selectionManager.setSelectedRange( - NSRange( - location: min(startPosition, endPosition), - length: max(startPosition, endPosition) - min(startPosition, endPosition) - ) - ) - setNeedsDisplay() - self.autoscroll(with: event) - } - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift deleted file mode 100644 index 7beb92310..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// TextView+Move.swift -// -// -// Created by Khan Winter on 9/10/23. -// - -import Foundation - -extension TextView { - fileprivate func updateAfterMove() { - unmarkTextIfNeeded() - scrollSelectionToVisible() - } - - /// Moves the cursors up one character. - override public func moveUp(_ sender: Any?) { - selectionManager.moveSelections(direction: .up, destination: .character) - updateAfterMove() - } - - /// Moves the cursors up one character extending the current selection. - override public func moveUpAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .up, destination: .character, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors down one character. - override public func moveDown(_ sender: Any?) { - selectionManager.moveSelections(direction: .down, destination: .character) - updateAfterMove() - } - - /// Moves the cursors down one character extending the current selection. - override public func moveDownAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .down, destination: .character, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors left one character. - override public func moveLeft(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .character) - updateAfterMove() - } - - /// Moves the cursors left one character extending the current selection. - override public func moveLeftAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .character, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors right one character. - override public func moveRight(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .character) - updateAfterMove() - } - - /// Moves the cursors right one character extending the current selection. - override public func moveRightAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .character, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors left one word. - override public func moveWordLeft(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .word) - updateAfterMove() - } - - /// Moves the cursors left one word extending the current selection. - override public func moveWordLeftAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .word, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors right one word. - override public func moveWordRight(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .word) - updateAfterMove() - } - - /// Moves the cursors right one word extending the current selection. - override public func moveWordRightAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .word, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors left to the end of the line. - override public func moveToLeftEndOfLine(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .visualLine) - updateAfterMove() - } - - /// Moves the cursors left to the end of the line extending the current selection. - override public func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .visualLine, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors right to the end of the line. - override public func moveToRightEndOfLine(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .visualLine) - updateAfterMove() - } - - /// Moves the cursors right to the end of the line extending the current selection. - override public func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .visualLine, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors to the beginning of the line, if pressed again selects the next line up. - override public func moveToBeginningOfParagraph(_ sender: Any?) { - selectionManager.moveSelections(direction: .up, destination: .line) - updateAfterMove() - } - - /// Moves the cursors to the beginning of the line, if pressed again selects the next line up extending the current - /// selection. - override public func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .up, destination: .line, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors to the end of the line, if pressed again selects the next line up. - override public func moveToEndOfParagraph(_ sender: Any?) { - selectionManager.moveSelections(direction: .down, destination: .line) - updateAfterMove() - } - - /// Moves the cursors to the end of the line, if pressed again selects the next line up extending the current - /// selection. - override public func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .down, destination: .line, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors to the beginning of the document. - override public func moveToBeginningOfDocument(_ sender: Any?) { - selectionManager.moveSelections(direction: .up, destination: .document) - updateAfterMove() - } - - /// Moves the cursors to the beginning of the document extending the current selection. - override public func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .up, destination: .document, modifySelection: true) - updateAfterMove() - } - - /// Moves the cursors to the end of the document. - override public func moveToEndOfDocument(_ sender: Any?) { - selectionManager.moveSelections(direction: .down, destination: .document) - updateAfterMove() - } - - /// Moves the cursors to the end of the document extending the current selection. - override public func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .down, destination: .document, modifySelection: true) - updateAfterMove() - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift deleted file mode 100644 index a9274f329..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ /dev/null @@ -1,282 +0,0 @@ -// -// TextView+NSTextInput.swift -// -// -// Created by Khan Winter on 7/16/23. -// - -import AppKit - -/// # Notes for Marked Text -/// -/// Marked text is used when a character may need more than one keystroke to insert text. For example pressing option-e -/// then e again to insert the é character. -/// -/// The text view needs to maintain a range of marked text and apply attributes indicating the text is marked. When -/// selection is updated, the marked text range can be discarded if the cursor leaves the marked text range. -/// -/// ## Notes for multiple cursors -/// -/// When inserting using multiple cursors, the marked text should be duplicated across all insertion points. However -/// this should only happen if the `setMarkedText` method is called with `NSNotFound` for the replacement range's -/// location (indicating that the marked text should appear at the insertion location) -/// -/// **Note: Visual studio code Does Not correctly support marked text with multiple cursors,* -/// **use Xcode as an example of this behavior.* -/// -/// All documentation in these methods is from the `NSTextInputClient` documentation, copied here for easy of use. -extension TextView: NSTextInputClient { - // MARK: - Insert Text - - /// Converts an `Any` to a valid string type if possible. - /// Throws an `assertionFailure` if not a valid type (`NSAttributedString`, `NSString`, or `String`) - private func anyToString(_ string: Any) -> String? { - switch string { - case let string as NSString: - return string as String - case let string as NSAttributedString: - return string.string - default: - assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.") - return nil - } - } - - /// Inserts the string at the replacement range. If replacement range is `NSNotFound`, uses the selection ranges. - private func _insertText(insertString: String, replacementRange: NSRange) { - var insertString = insertString - if LineEnding(rawValue: insertString) == .carriageReturn - && layoutManager.detectedLineEnding == .carriageReturnLineFeed { - insertString = LineEnding.carriageReturnLineFeed.rawValue - } - - if replacementRange.location == NSNotFound { - replaceCharacters(in: selectionManager.textSelections.map(\.range), with: insertString) - } else { - replaceCharacters(in: replacementRange, with: insertString) - } - - selectionManager.textSelections.forEach { $0.suggestedXPos = nil } - } - - /// Inserts the given string into the receiver, replacing the specified content. - /// - /// Programmatic modification of the text is best done by operating on the text storage directly. - /// Because this method pertains to the actions of the user, the text view must be editable for the - /// insertion to work. - /// - /// - Parameters: - /// - string: The text to insert, either an NSString or NSAttributedString instance. - /// - replacementRange: The range of content to replace in the receiver’s text storage. - @objc public func insertText(_ string: Any, replacementRange: NSRange) { - guard isEditable, let insertString = anyToString(string) else { return } - unmarkText() - _insertText(insertString: insertString, replacementRange: replacementRange) - } - - override public func insertText(_ insertString: Any) { - insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0)) - } - - // MARK: - Marked Text - - /// Replaces a specified range in the receiver’s text storage with the given string and sets the selection. - /// - /// If there is no marked text, the current selection is replaced. If there is no selection, the string is - /// inserted at the insertion point. - /// - /// When `string` is an `NSString` object, the receiver is expected to render the marked text with - /// distinguishing appearance (for example, `NSTextView` renders with `markedTextAttributes`). - /// - /// - Parameters: - /// - string: The string to insert. Can be either an NSString or NSAttributedString instance. - /// - selectedRange: The range to set as the selection, computed from the beginning of the inserted string. - /// - replacementRange: The range to replace, computed from the beginning of the marked text. - @objc public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - guard isEditable, let insertString = anyToString(string) else { return } - // Needs to insert text, but not notify the undo manager. - _undoManager?.disable() - layoutManager.markedTextManager.updateMarkedRanges( - insertLength: (insertString as NSString).length, - replacementRange: replacementRange, - selectedRange: selectedRange, - textSelections: selectionManager.textSelections - ) - _insertText(insertString: insertString, replacementRange: replacementRange) - _undoManager?.enable() - } - - /// Unmarks text and causes layout if needed after a selection update. - func unmarkTextIfNeeded() { - guard layoutManager.markedTextManager.hasMarkedText, - layoutManager.markedTextManager.updateForNewSelections( - textSelections: selectionManager.textSelections - ) else { - return - } - - layoutManager.markedTextManager.removeAll() - layoutManager.setNeedsLayout() - needsLayout = true - inputContext?.discardMarkedText() - } - - /// Unmarks the marked text. - /// - /// The receiver removes any marking from pending input text and disposes of the marked text as it wishes. - /// The text view should accept the marked text as if it had been inserted normally. - /// If there is no marked text, the invocation of this method has no effect. - @objc public func unmarkText() { - if layoutManager.markedTextManager.hasMarkedText { - _undoManager?.disable() - replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: "") - _undoManager?.enable() - layoutManager.markedTextManager.removeAll() - layoutManager.setNeedsLayout() - needsLayout = true - inputContext?.discardMarkedText() - } - } - - /// Returns the range of selected text. - /// The returned range measures from the start of the receiver’s text storage, that is, from 0 to the document - /// length. - /// - Returns: The range of selected text or {NSNotFound, 0} if there is no selection. - @objc public func selectedRange() -> NSRange { - return selectionManager?.textSelections.first?.range ?? NSRange(location: NSNotFound, length: 0) - } - - /// Returns the range of the marked text. - /// - /// The returned range measures from the start of the receiver’s text storage. The return value’s location is - /// `NSNotFound` and its length is `0` if and only if `hasMarkedText()` returns false. - /// - /// - Returns: The range of marked text or {NSNotFound, 0} if there is no marked range. - @objc public func markedRange() -> NSRange { - return layoutManager?.markedTextManager.markedRanges.first ?? NSRange(location: NSNotFound, length: 0) - } - - /// Returns a Boolean value indicating whether the receiver has marked text. - /// - /// The text view itself may call this method to determine whether there currently is marked text. - /// NSTextView, for example, disables the Edit > Copy menu item when this method returns true. - /// - /// - Returns: true if the receiver has marked text; otherwise false. - @objc public func hasMarkedText() -> Bool { - return layoutManager.markedTextManager.hasMarkedText - } - - /// Returns an array of attribute names recognized by the receiver. - /// - /// Returns an empty array if no attributes are supported. See NSAttributedString Application Kit Additions - /// Reference for the set of string constants representing standard attributes. - /// - /// - Returns: An array of NSString objects representing names for the supported attributes. - @objc public func validAttributesForMarkedText() -> [NSAttributedString.Key] { - [.underlineStyle, .underlineColor, .backgroundColor, .font, .foregroundColor] - } - - // MARK: - Contents - - /// Returns an attributed string derived from the given range in the receiver's text storage. - /// - /// An implementation of this method should be prepared for aRange to be out of bounds. - /// For example, the InkWell text input service can ask for the contents of the text input client - /// that extends beyond the document’s range. In this case, you should return the - /// intersection of the document’s range and aRange. If the location of aRange is completely outside of the - /// document’s range, return nil. - /// - /// - Parameters: - /// - range: The range in the text storage from which to create the returned string. - /// - actualRange: The actual range of the returned string if it was adjusted, for example, to a grapheme cluster - /// boundary or for performance or other reasons. NULL if range was not adjusted. - /// - Returns: The string created from the given range. May return nil. - @objc public func attributedSubstring( - forProposedRange range: NSRange, - actualRange: NSRangePointer? - ) -> NSAttributedString? { - let realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: range) - actualRange?.pointee = realRange - return textStorage.attributedSubstring(from: realRange) - } - - /// Returns an attributed string representing the receiver's text storage. - /// - Returns: The attributed string of the receiver’s text storage. - @objc public func attributedString() -> NSAttributedString { - textStorage.attributedSubstring(from: documentRange) - } - - // MARK: - Positions - - /// Returns the first logical boundary rectangle for characters in the given range. - /// - Parameters: - /// - range: The character range whose boundary rectangle is returned. - /// - actualRange: If non-NULL, contains the character range corresponding to the returned area if it was - /// adjusted, for example, to a grapheme cluster boundary or characters in the first line fragment. - /// - Returns: The boundary rectangle for the given range of characters, in *screen* coordinates. - /// The rectangle’s size value can be negative if the text flows to the left. - @objc public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - if actualRange != nil { - let realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: range) - if realRange != range { - actualRange?.pointee = realRange - } - } - - let localRect = (layoutManager.rectForOffset(range.location) ?? .zero) - let windowRect = convert(localRect, to: nil) - return window?.convertToScreen(windowRect) ?? .zero - } - - /// Returns the index of the character whose bounding rectangle includes the given point. - /// - Parameter point: The point to test, in *screen* coordinates. - /// - Returns: The character index, measured from the start of the receiver’s text storage, of the character - /// containing the given point. Returns NSNotFound if the cursor is not within a character’s - /// bounding rectangle. - @objc public func characterIndex(for point: NSPoint) -> Int { - guard let windowPoint = window?.convertPoint(fromScreen: point) else { - return NSNotFound - } - let localPoint = convert(windowPoint, from: nil) - return layoutManager.textOffsetAtPoint(localPoint) ?? NSNotFound - } - - /// Returns the fraction of the distance from the left side of the character to the right side that a given point - /// lies. - /// - /// For purposes such as dragging out a selection or placing the insertion point, a partial percentage less than or - /// equal to 0.5 indicates that aPoint should be considered as falling before the glyph; a partial percentage - /// greater than 0.5 indicates that it should be considered as falling after the glyph. If the nearest glyph doesn’t - /// lie under aPoint at all (for example, if aPoint is beyond the beginning or end of a line), this ratio is 0 or 1. - /// - /// For example, if the glyph stream contains the glyphs “A” and “b”, with the width of “A” being 13 points, and - /// aPoint is 8 points from the left side of “A”, then the fraction of the distance is 8/13, or 0.615. In this - /// case, the aPoint should be considered as falling between “A” and “b” for purposes such as dragging out a - /// selection or placing the insertion point. - /// - /// - Parameter point: The point to test. - /// - Returns: The fraction of the distance aPoint is through the glyph in which it lies. May be 0 or 1 if aPoint - /// is not within the bounding rectangle of a glyph (0 if the point is to the left or above the glyph; - /// 1 if it's to the right or below). - @objc public func fractionOfDistanceThroughGlyph(for point: NSPoint) -> CGFloat { - guard let offset = layoutManager.textOffsetAtPoint(point), - let characterRect = layoutManager.rectForOffset(offset) else { return 0 } - return (point.x - characterRect.minX)/characterRect.width - } - - /// Returns the baseline position of a given character relative to the origin of rectangle returned by - /// `firstRect(forCharacterRange:actualRange:)`. - /// - Parameter anIndex: Index of the character whose baseline is tested. - /// - Returns: The vertical distance, in points, between the baseline of the character at anIndex and the rectangle - /// origin. - @objc public func baselineDeltaForCharacter(at anIndex: Int) -> CGFloat { - // Return the `descent` value from the line fragment at the index - guard let linePosition = layoutManager.textLineForOffset(anIndex), - let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( - atOffset: anIndex - linePosition.range.location - ) else { - return 0 - } - return fragmentPosition.data.descent - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift deleted file mode 100644 index 4e4b662d1..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// TextView+ReplaceCharacters.swift -// -// -// Created by Khan Winter on 9/3/23. -// - -import AppKit -import TextStory - -extension TextView { - // MARK: - Replace Characters - - /// Replace the characters in the given ranges with the given string. - /// - Parameters: - /// - ranges: The ranges to replace - /// - string: The string to insert in the ranges. - public func replaceCharacters(in ranges: [NSRange], with string: String) { - guard isEditable else { return } - NotificationCenter.default.post(name: Self.textWillChangeNotification, object: self) - layoutManager.beginTransaction() - textStorage.beginEditing() - // Can't insert an ssempty string into an empty range. One must be not empty - for range in ranges.sorted(by: { $0.location > $1.location }) where - (delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) - && (!range.isEmpty || !string.isEmpty) { - 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) - ) - textStorage.replaceCharacters( - in: range, - with: NSAttributedString(string: string, attributes: typingAttributes) - ) - selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length) - - delegate?.textView(self, didReplaceContentsIn: range, with: string) - } - layoutManager.endTransaction() - textStorage.endEditing() - selectionManager.notifyAfterEdit() - NotificationCenter.default.post(name: Self.textDidChangeNotification, object: self) - } - - /// Replace the characters in a range with a new string. - /// - 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) - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Select.swift b/Sources/CodeEditInputView/TextView/TextView+Select.swift deleted file mode 100644 index bff72c237..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Select.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// TextView+Select.swift -// -// -// Created by Khan Winter on 10/20/23. -// - -import AppKit -import TextStory - -extension TextView { - override public func selectAll(_ sender: Any?) { - selectionManager.setSelectedRange(documentRange) - unmarkTextIfNeeded() - needsDisplay = true - } - - override public func selectLine(_ sender: Any?) { - let newSelections = selectionManager.textSelections.compactMap { textSelection -> NSRange? in - guard let linePosition = layoutManager.textLineForOffset(textSelection.range.location) else { - return nil - } - return linePosition.range - } - selectionManager.setSelectedRanges(newSelections) - unmarkTextIfNeeded() - needsDisplay = true - } - - 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 - } - return NSRange( - location: start, - length: end - start - ) - } - selectionManager.setSelectedRanges(newSelections) - unmarkTextIfNeeded() - needsDisplay = true - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift deleted file mode 100644 index 7eea47bff..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+Setup.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// TextView+Setup.swift -// -// -// Created by Khan Winter on 9/15/23. -// - -import AppKit - -extension TextView { - func setUpLayoutManager(lineHeightMultiplier: CGFloat, wrapLines: Bool) -> TextLayoutManager { - TextLayoutManager( - textStorage: textStorage, - lineHeightMultiplier: lineHeightMultiplier, - wrapLines: wrapLines, - textView: self, - delegate: self - ) - } - - func setUpSelectionManager() -> TextSelectionManager { - TextSelectionManager( - layoutManager: layoutManager, - textStorage: textStorage, - layoutView: self, - delegate: self - ) - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift b/Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift deleted file mode 100644 index d13bd234d..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// TextView+StorageDelegate.swift -// -// -// Created by Khan Winter on 11/8/23. -// - -import AppKit - -extension TextView { - public func addStorageDelegate(_ delegate: NSTextStorageDelegate) { - storageDelegate.addDelegate(delegate) - } - - public func removeStorageDelegate(_ delegate: NSTextStorageDelegate) { - storageDelegate.removeDelegate(delegate) - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift deleted file mode 100644 index d89a635a7..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// TextView+TextLayoutManagerDelegate.swift -// -// -// Created by Khan Winter on 9/15/23. -// - -import AppKit - -extension TextView: TextLayoutManagerDelegate { - public func layoutManagerHeightDidUpdate(newHeight: CGFloat) { - updateFrameIfNeeded() - } - - public func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { - updateFrameIfNeeded() - } - - public func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { - typingAttributes - } - - public func textViewportSize() -> CGSize { - if let scrollView = scrollView { - var size = scrollView.contentSize - size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom - return size - } else { - return CGSize(width: CGFloat.infinity, height: CGFloat.infinity) - } - } - - public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { - var point = scrollView?.documentVisibleRect.origin ?? .zero - point.y += yAdjustment - scrollView?.documentView?.scroll(point) - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift deleted file mode 100644 index 4e546d150..000000000 --- a/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// TextView+UndoRedo.swift -// -// -// Created by Khan Winter on 8/21/23. -// - -import AppKit - -extension TextView { - public func setUndoManager(_ newManager: CEUndoManager) { - self._undoManager = newManager - } - - override public var undoManager: UndoManager? { - _undoManager?.manager - } - - @objc func undo(_ sender: AnyObject?) { - if allowsUndo { - undoManager?.undo() - } - } - - @objc func redo(_ sender: AnyObject?) { - if allowsUndo { - undoManager?.redo() - } - } - -} diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift deleted file mode 100644 index e54274fd1..000000000 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ /dev/null @@ -1,499 +0,0 @@ -// -// TextView.swift -// -// -// Created by Khan Winter on 6/21/23. -// - -import AppKit -import TextStory - -// Disabling file length and type body length as the methods and variables contained in this file cannot be moved -// to extensions. -// swiftlint:disable type_body_length - -/// # Text View -/// -/// A view that draws and handles user interactions with text. -/// Optimized for line-based documents, does not attempt to have feature parity with `NSTextView`. -/// -/// The text view maintains multiple helper classes for selecting, editing, and laying out text. -/// ``` -/// TextView -/// |-> NSTextStorage Base text storage. -/// |-> TextLayoutManager Creates, manages, and lays out text lines. -/// | |-> TextLineStorage Extremely fast object for storing and querying lines of text. Does not store text. -/// | |-> [TextLine] Represents a line of text. -/// | | |-> Typesetter Calculates line breaks and other layout information for text lines. -/// | | |-> [LineFragment] Represents a visual line of text, stored in an internal line storage object. -/// | |-> [LineFragmentView] Reusable line fragment view that draws a line fragment. -/// | |-> MarkedRangeManager Manages marked ranges, updates layout if needed. -/// | -/// |-> TextSelectionManager Maintains, modifies, and renders text selections -/// | |-> [TextSelection] Represents a range of selected text. -/// ``` -/// -/// Conforms to [`NSTextContent`](https://developer.apple.com/documentation/appkit/nstextcontent) and -/// [`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 { - // MARK: - Statics - - /// The default typing attributes: - /// - font: System font, size 12 - /// - foregroundColor: System text color - /// - kern: 0.0 - public static var defaultTypingAttributes: [NSAttributedString.Key: Any] { - [.font: NSFont.systemFont(ofSize: 12), .foregroundColor: NSColor.textColor, .kern: 0.0] - } - - // swiftlint:disable:next line_length - public static let textDidChangeNotification: Notification.Name = .init(rawValue: "com.CodeEdit.TextView.TextDidChangeNotification") - - // swiftlint:disable:next line_length - public static let textWillChangeNotification: Notification.Name = .init(rawValue: "com.CodeEdit.TextView.TextWillChangeNotification") - - // MARK: - Configuration - - /// The string for the text view. - public var string: String { - get { - textStorage.string - } - set { - layoutManager.willReplaceCharactersInRange(range: documentRange, with: newValue) - textStorage.setAttributedString(NSAttributedString(string: newValue, attributes: typingAttributes)) - } - } - - /// The attributes to apply to inserted text. - public var typingAttributes: [NSAttributedString.Key: Any] = [:] { - didSet { - setNeedsDisplay() - layoutManager?.setNeedsLayout() - } - } - - /// The default font of the text view. - public var font: NSFont { - get { - (typingAttributes[.font] as? NSFont) ?? NSFont.systemFont(ofSize: 12) - } - set { - typingAttributes[.font] = newValue - } - } - - /// The text color of the text view. - public var textColor: NSColor { - get { - (typingAttributes[.foregroundColor] as? NSColor) ?? NSColor.textColor - } - set { - typingAttributes[.foregroundColor] = newValue - } - } - - /// The line height as a multiple of the font's line height. 1.0 represents no change in height. - public var lineHeight: CGFloat { - get { - layoutManager?.lineHeightMultiplier ?? 1.0 - } - set { - layoutManager?.lineHeightMultiplier = newValue - } - } - - /// Whether or not the editor should wrap lines - public var wrapLines: Bool { - get { - layoutManager?.wrapLines ?? false - } - set { - layoutManager?.wrapLines = newValue - } - } - - /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, - /// `2.0` indicates one character of space between other characters. - public var letterSpacing: Double { - didSet { - kern = fontCharWidth * (letterSpacing - 1.0) - layoutManager.setNeedsLayout() - } - } - - /// Determines if the text view's content can be edited. - public var isEditable: Bool { - didSet { - setNeedsDisplay() - selectionManager.updateSelectionViews() - if !isEditable && isFirstResponder { - _ = resignFirstResponder() - } - } - } - - /// Determines if the text view responds to selection events, such as clicks. - public var isSelectable: Bool = true { - didSet { - if !isSelectable { - selectionManager.removeCursors() - if isFirstResponder { - _ = resignFirstResponder() - } - } - setNeedsDisplay() - } - } - - /// The edge insets for the text view. - public var edgeInsets: HorizontalEdgeInsets { - get { - layoutManager?.edgeInsets ?? .zero - } - set { - layoutManager?.edgeInsets = newValue - } - } - - /// The kern to use for characters. Defaults to `0.0` and is updated when `letterSpacing` is set. - public var kern: CGFloat { - get { - typingAttributes[.kern] as? CGFloat ?? 0 - } - set { - typingAttributes[.kern] = newValue - } - } - - /// The strategy to use when breaking lines. Defaults to ``LineBreakStrategy/word``. - public var lineBreakStrategy: LineBreakStrategy { - get { - layoutManager?.lineBreakStrategy ?? .word - } - set { - layoutManager.lineBreakStrategy = newValue - } - } - - open var contentType: NSTextContentType? - - /// The text view's delegate. - public weak var delegate: TextViewDelegate? - - /// The text storage object for the text view. - /// - Warning: Do not update the text storage object directly. Doing so will very likely break the text view's - /// layout system. Use methods like ``TextView/replaceCharacters(in:with:)-3h9uo`` or - /// ``TextView/insertText(_:)`` to modify content. - private(set) public var textStorage: NSTextStorage! - /// The layout manager for the text view. - private(set) public var layoutManager: TextLayoutManager! - /// The selection manager for the text view. - private(set) public var selectionManager: TextSelectionManager! - - // MARK: - Private Properties - - var isFirstResponder: Bool = false - var mouseDragAnchor: CGPoint? - var mouseDragTimer: Timer? - - private var fontCharWidth: CGFloat { - (" " as NSString).size(withAttributes: [.font: font]).width - } - - var _undoManager: CEUndoManager? - @objc dynamic open var allowsUndo: Bool - - var scrollView: NSScrollView? { - guard let enclosingScrollView, enclosingScrollView.documentView == self else { return nil } - return enclosingScrollView - } - - var storageDelegate: MultiStorageDelegate! - - // MARK: - Init - - /// Initializes the text view. - /// - Parameters: - /// - string: The contents of the text view. - /// - font: The default font. - /// - textColor: The default text color. - /// - lineHeightMultiplier: The multiplier to use for line heights. - /// - wrapLines: Determines how the view will wrap lines to the viewport. - /// - isEditable: Determines if the view is editable. - /// - isSelectable: Determines if the view is selectable. - /// - letterSpacing: Sets the letter spacing on the view. - /// - delegate: The text view's delegate. - public init( - string: String, - font: NSFont, - textColor: NSColor, - lineHeightMultiplier: CGFloat, - wrapLines: Bool, - isEditable: Bool, - isSelectable: Bool, - letterSpacing: Double, - delegate: TextViewDelegate - ) { - self.textStorage = NSTextStorage(string: string) - self.delegate = delegate - self.isEditable = isEditable - self.isSelectable = isSelectable - self.letterSpacing = letterSpacing - self.allowsUndo = true - - super.init(frame: .zero) - - self.storageDelegate = MultiStorageDelegate() - - wantsLayer = true - postsFrameChangedNotifications = true - postsBoundsChangedNotifications = true - autoresizingMask = [.width, .height] - - self.typingAttributes = [ - .font: font, - .foregroundColor: textColor, - ] - - textStorage.addAttributes(typingAttributes, range: documentRange) - textStorage.delegate = storageDelegate - - layoutManager = setUpLayoutManager(lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines) - storageDelegate.addDelegate(layoutManager) - selectionManager = setUpSelectionManager() - - _undoManager = CEUndoManager(textView: self) - - layoutManager.layoutLines() - setUpDragGesture() - } - - /// Sets the text view's text to a new value. - /// - Parameter text: The new contents of the text view. - public func setText(_ text: String) { - let newStorage = NSTextStorage(string: text) - self.setTextStorage(newStorage) - } - - /// Set a new text storage object for the view. - /// - Parameter textStorage: The new text storage to use. - public func setTextStorage(_ textStorage: NSTextStorage) { - self.textStorage = textStorage - - subviews.forEach { view in - view.removeFromSuperview() - } - - textStorage.addAttributes(typingAttributes, range: documentRange) - layoutManager.textStorage = textStorage - layoutManager.reset() - - selectionManager.textStorage = textStorage - selectionManager.setSelectedRanges(selectionManager.textSelections.map { $0.range }) - - _undoManager?.clearStack() - - textStorage.delegate = storageDelegate - needsDisplay = true - needsLayout = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public var documentRange: NSRange { - NSRange(location: 0, length: textStorage.length) - } - - // MARK: - First Responder - - open override func becomeFirstResponder() -> Bool { - isFirstResponder = true - return super.becomeFirstResponder() - } - - open override func resignFirstResponder() -> Bool { - isFirstResponder = false - selectionManager.removeCursors() - return super.resignFirstResponder() - } - - open override var canBecomeKeyView: Bool { - super.canBecomeKeyView && acceptsFirstResponder && !isHiddenOrHasHiddenAncestor - } - - open override var needsPanelToBecomeKey: Bool { - isSelectable || isEditable - } - - open override var acceptsFirstResponder: Bool { - isSelectable - } - - open override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - return true - } - - open override func resetCursorRects() { - super.resetCursorRects() - if isSelectable { - addCursorRect(visibleRect, cursor: .iBeam) - } - } - - // 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 viewDidEndLiveResize() { - super.viewDidEndLiveResize() - updateFrameIfNeeded() - } - - // MARK: - Key Down - - override public func keyDown(with event: NSEvent) { - guard isEditable else { - super.keyDown(with: event) - return - } - - NSCursor.setHiddenUntilMouseMoves(true) - - if !(inputContext?.handleEvent(event) ?? false) { - interpretKeyEvents([event]) - } else { - // Handle key events? - } - } - - // MARK: - Layout - - override public func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - if isSelectable { - selectionManager.drawSelections(in: dirtyRect) - } - } - - override open var isFlipped: Bool { - true - } - - override public var visibleRect: NSRect { - if let scrollView = scrollView { - var rect = scrollView.documentVisibleRect - rect.origin.y += scrollView.contentInsets.top - rect.size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom - return rect - } else { - return super.visibleRect - } - } - - public var visibleTextRange: NSRange? { - let minY = max(visibleRect.minY, 0) - let maxY = min(visibleRect.maxY, layoutManager.estimatedHeight()) - guard let minYLine = layoutManager.textLineForPosition(minY), - let maxYLine = layoutManager.textLineForPosition(maxY) else { - return nil - } - return NSRange( - location: minYLine.range.location, - length: (maxYLine.range.location - minYLine.range.location) + maxYLine.range.length - ) - } - - public func updatedViewport(_ newRect: CGRect) { - if !updateFrameIfNeeded() { - layoutManager.layoutLines() - } - inputContext?.invalidateCharacterCoordinates() - } - - @discardableResult - 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 newWidth = layoutManager.estimatedWidth() - - var didUpdate = false - - if newHeight >= availableSize.height && frame.size.height != newHeight { - frame.size.height = newHeight - // No need to update layout after height adjustment - } - - if wrapLines && frame.size.width != availableSize.width { - frame.size.width = availableSize.width - didUpdate = true - } else if !wrapLines && frame.size.width != max(newWidth, availableSize.width) { - frame.size.width = max(newWidth, availableSize.width) - didUpdate = true - } - - if didUpdate { - needsLayout = true - needsDisplay = true - layoutManager.layoutLines() - } - - if isSelectable { - selectionManager?.updateSelectionViews() - } - - return didUpdate - } - - /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. - public func scrollSelectionToVisible() { - guard let scrollView, - let selection = selectionManager.textSelections - .sorted(by: { $0.boundingRect.origin.y < $1.boundingRect.origin.y }).first else { - return - } - var lastFrame: CGRect = .zero - while lastFrame != selection.boundingRect { - lastFrame = selection.boundingRect - layoutManager.layoutLines() - selectionManager.updateSelectionViews() - selectionManager.drawSelections(in: visibleRect) - } - scrollView.contentView.scrollToVisible(lastFrame) - } - - deinit { - layoutManager = nil - selectionManager = nil - textStorage = nil - NotificationCenter.default.removeObserver(self) - } -} - -// MARK: - TextSelectionManagerDelegate - -extension TextView: TextSelectionManagerDelegate { - public func setNeedsDisplay() { - self.setNeedsDisplay(frame) - } - - public func estimatedLineHeight() -> CGFloat { - layoutManager.estimateLineHeight() - } -} - -// swiftlint:enable type_body_length -// swiftlint:disable:this file_length diff --git a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift deleted file mode 100644 index b977408c4..000000000 --- a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// TextViewDelegate.swift -// -// -// Created by Khan Winter on 9/3/23. -// - -import Foundation - -public protocol TextViewDelegate: AnyObject { - func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) - func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) - func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool -} - -public extension TextViewDelegate { - func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) { } - func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { } - func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { true } -} diff --git a/Sources/CodeEditInputView/Utils/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift deleted file mode 100644 index b65e033cf..000000000 --- a/Sources/CodeEditInputView/Utils/CEUndoManager.swift +++ /dev/null @@ -1,227 +0,0 @@ -// -// CEUndoManager.swift -// CodeEditTextView -// -// Created by Khan Winter on 7/8/23. -// - -import AppKit -import TextStory - -/// Maintains a history of edits applied to the editor and allows for undo/redo actions using those edits. -/// -/// This object also groups edits into sequences that make for a better undo/redo editing experience such as: -/// - Breaking undo groups on newlines -/// - 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 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 registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { - // no-op, but just in case to save resources: - removeAllActions() - } - } - - /// Represents a group of mutations that should be treated as one mutation when undoing/redoing. - private struct UndoGroup { - var mutations: [Mutation] - } - - /// A single undo mutation. - private struct Mutation { - var mutation: TextMutation - var inverse: TextMutation - } - - public let manager: DelegatedUndoManager - private(set) public var isUndoing: Bool = false - private(set) public var isRedoing: Bool = false - - public var canUndo: Bool { - !undoStack.isEmpty - } - public var canRedo: Bool { - !redoStack.isEmpty - } - - /// A stack of operations that can be undone. - private var undoStack: [UndoGroup] = [] - /// A stack of operations that can be redone. - private var redoStack: [UndoGroup] = [] - - private weak var textView: TextView? - private(set) public var isGrouping: Bool = false - /// True when the manager is ignoring mutations. - private var isDisabled: Bool = false - - // MARK: - Init - - public init() { - self.manager = DelegatedUndoManager() - manager.parent = self - } - - convenience init(textView: TextView) { - self.init() - self.textView = textView - } - - // MARK: - Undo/Redo - - /// Performs an undo operation if there is one available. - public func undo() { - guard !isDisabled, let item = undoStack.popLast(), let textView else { - return - } - isUndoing = true - for mutation in item.mutations.reversed() { - NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self.manager) - textView.insertText(mutation.inverse.string, replacementRange: mutation.inverse.range) - NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager) - } - redoStack.append(item) - isUndoing = false - } - - /// Performs a redo operation if there is one available. - public func redo() { - guard !isDisabled, let item = redoStack.popLast(), let textView else { - return - } - isRedoing = true - for mutation in item.mutations { - NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self.manager) - textView.insertText(mutation.mutation.string, replacementRange: mutation.mutation.range) - NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager) - } - undoStack.append(item) - isRedoing = false - } - - /// Clears the undo/redo stacks. - public func clearStack() { - undoStack.removeAll() - redoStack.removeAll() - } - - // MARK: - Mutations - - /// 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) { - guard let textView, - let textStorage = textView.textStorage, - !isUndoing, - !isRedoing else { - 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])) - } - } else { - undoStack.append( - UndoGroup(mutations: [newMutation]) - ) - } - - redoStack.removeAll() - } - - // MARK: - Grouping - - /// Groups all incoming mutations. - public func beginGrouping() { - isGrouping = true - } - - /// Stops grouping all incoming mutations. - public func endGrouping() { - isGrouping = false - } - - /// Determines whether or not two mutations should be grouped. - /// - /// Will break group if: - /// - Last mutation is delete and new is insert, and vice versa *(insert and delete)*. - /// - Last mutation was not whitespace, new is whitespace *(insert)*. - /// - New mutation is a newline *(insert and delete)*. - /// - New mutation is not sequential with the last one *(insert and delete)*. - /// - /// - Parameters: - /// - mutation: The current mutation. - /// - lastMutation: The last mutation applied to the document. - /// - Returns: Whether or not the given mutations can be grouped. - private func shouldContinueGroup(_ mutation: Mutation, lastMutation: Mutation) -> Bool { - // If last mutation was delete & new is insert or vice versa, split group - if (mutation.mutation.range.length > 0 && lastMutation.mutation.range.length == 0) - || (mutation.mutation.range.length == 0 && lastMutation.mutation.range.length > 0) { - return false - } - - if mutation.mutation.string.isEmpty { - // Deleting - return ( - lastMutation.mutation.range.location == mutation.mutation.range.max - && mutation.inverse.string != "\n" - ) - } else { - // Inserting - - // Only attempt this check if the mutations are small enough. - // If the last mutation was not whitespace, and the new one is, break the group. - if lastMutation.mutation.string.count < 1024 - && mutation.mutation.string.count < 1024 - && !lastMutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty - && mutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty { - return false - } - - return ( - lastMutation.mutation.range.max + 1 == mutation.mutation.range.location - && mutation.mutation.string != "\n" - ) - } - } - - // MARK: - Disable - - /// Sets the undo manager to ignore incoming mutations until the matching `enable` method is called. - /// Cannot be nested. - public func disable() { - isDisabled = true - } - - /// Sets the undo manager to begin receiving incoming mutations after a call to `disable` - /// Cannot be nested. - public func enable() { - isDisabled = false - } -} diff --git a/Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift b/Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift deleted file mode 100644 index 133bf7b39..000000000 --- a/Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// HorizontalEdgeInsets.swift -// -// -// Created by Khan Winter on 9/11/23. -// - -import Foundation - -public struct HorizontalEdgeInsets: Codable, Sendable, Equatable { - public var left: CGFloat - public var right: CGFloat - - public var horizontal: CGFloat { - left + right - } - - public init(left: CGFloat, right: CGFloat) { - self.left = left - self.right = right - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.left = try container.decode(CGFloat.self, forKey: .left) - self.right = try container.decode(CGFloat.self, forKey: .right) - } - - public static let zero: HorizontalEdgeInsets = { - HorizontalEdgeInsets(left: 0, right: 0) - }() -} diff --git a/Sources/CodeEditInputView/Utils/LineEnding.swift b/Sources/CodeEditInputView/Utils/LineEnding.swift deleted file mode 100644 index 2e5051d22..000000000 --- a/Sources/CodeEditInputView/Utils/LineEnding.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// LineEnding.swift -// -// -// Created by Khan Winter on 8/16/23. -// - -import AppKit - -public enum LineEnding: String, CaseIterable { - /// The default unix `\n` character - case lineFeed = "\n" - /// MacOS line ending `\r` character - case carriageReturn = "\r" - /// Windows line ending sequence `\r\n` - case carriageReturnLineFeed = "\r\n" - - /// Initialize a line ending from a line string. - /// - Parameter line: The line to use - public init?(line: String) { - guard let lastChar = line.last, - let lineEnding = LineEnding(rawValue: String(lastChar)) else { return nil } - self = lineEnding - } - - /// Attempts to detect the line ending from a line storage. - /// - Parameter lineStorage: The line storage to enumerate. - /// - Returns: A line ending. Defaults to `.lf` if none could be found. - public static func detectLineEnding( - lineStorage: TextLineStorage, - textStorage: NSTextStorage - ) -> LineEnding { - var histogram: [LineEnding: Int] = LineEnding.allCases.reduce(into: [LineEnding: Int]()) { - $0[$1] = 0 - } - var shouldContinue = true - var lineIterator = lineStorage.makeIterator() - - while let line = lineIterator.next(), shouldContinue { - guard let lineString = textStorage.substring(from: line.range), - let lineEnding = LineEnding(line: lineString) else { - continue - } - histogram[lineEnding] = histogram[lineEnding]! + 1 - // after finding 15 lines of a line ending we assume it's correct. - if histogram[lineEnding]! >= 15 { - shouldContinue = false - } - } - - let orderedValues = histogram.sorted(by: { $0.value > $1.value }) - // Return the max of the histogram, but if there's no max - // we default to lineFeed. This should be a parameter in the future. - if orderedValues.count >= 2 { - if orderedValues[0].value == orderedValues[1].value { - return .lineFeed - } else { - return orderedValues[0].key - } - } else { - return .lineFeed - } - } - - /// The UTF-16 Length of the line ending. - public var length: Int { - rawValue.utf16.count - } -} diff --git a/Sources/CodeEditInputView/Utils/Logger.swift b/Sources/CodeEditInputView/Utils/Logger.swift deleted file mode 100644 index 543b8e5d7..000000000 --- a/Sources/CodeEditInputView/Utils/Logger.swift +++ /dev/null @@ -1,3 +0,0 @@ -import os - -let logger = Logger(subsystem: "com.CodeEdit.CodeEditTextView", category: "TextView") diff --git a/Sources/CodeEditInputView/Utils/MultiStorageDelegate.swift b/Sources/CodeEditInputView/Utils/MultiStorageDelegate.swift deleted file mode 100644 index cf4bcf0ea..000000000 --- a/Sources/CodeEditInputView/Utils/MultiStorageDelegate.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// MultiStorageDelegate.swift -// -// -// Created by Khan Winter on 6/25/23. -// - -import AppKit - -public class MultiStorageDelegate: NSObject, NSTextStorageDelegate { - private var delegates = NSHashTable.weakObjects() - - public func addDelegate(_ delegate: NSTextStorageDelegate) { - delegates.add(delegate) - } - - public func removeDelegate(_ delegate: NSTextStorageDelegate) { - delegates.remove(delegate) - } - - public func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - delegates.allObjects.forEach { delegate in - delegate.textStorage?(textStorage, didProcessEditing: editedMask, range: editedRange, changeInLength: delta) - } - } - - public func textStorage( - _ textStorage: NSTextStorage, - willProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - delegates.allObjects.forEach { delegate in - delegate - .textStorage?(textStorage, willProcessEditing: editedMask, range: editedRange, changeInLength: delta) - } - } -} diff --git a/Sources/CodeEditInputView/Utils/ViewReuseQueue.swift b/Sources/CodeEditInputView/Utils/ViewReuseQueue.swift deleted file mode 100644 index 3a044d5ee..000000000 --- a/Sources/CodeEditInputView/Utils/ViewReuseQueue.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// ViewReuseQueue.swift -// -// -// Created by Khan Winter on 8/14/23. -// - -import AppKit -import DequeModule - -/// Maintains a queue of views available for reuse. -public class ViewReuseQueue { - /// A stack of views that are not currently in use - public var queuedViews: Deque = [] - - /// Maps views that are no longer queued to the keys they're queued with. - public var usedViews: [Key: View] = [:] - - public init() { } - - /// Finds, dequeues, or creates a view for the given key. - /// - /// If the view has been dequeued, it will return the view already queued for the given key it will be returned. - /// 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. - /// - Returns: A view for the given key. - public func getOrCreateView(forKey key: Key) -> View { - let view: View - if let usedView = usedViews[key] { - view = usedView - } else { - view = queuedViews.popFirst() ?? View() - view.prepareForReuse() - usedViews[key] = view - } - return view - } - - /// 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 { - queuedViews.append(view) - } - usedViews.removeValue(forKey: key) - view.removeFromSuperviewWithoutNeedingDisplay() - } - - /// Enqueues all views not in the given set. - /// - Parameter outsideSet: The keys who's views should not be enqueued for reuse. - public func enqueueViews(notInSet keys: Set) { - // Get all keys that are in "use" but not in the given set. - for key in Set(usedViews.keys).subtracting(keys) { - enqueueView(forKey: key) - } - } - - /// Enqueues all views keyed by the given set. - /// - Parameter keys: The keys for all the views that should be enqueued. - public func enqueueViews(in keys: Set) { - for key in keys { - enqueueView(forKey: key) - } - } - - deinit { - usedViews.removeAll() - queuedViews.removeAll() - } -} diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor.swift similarity index 89% rename from Sources/CodeEditTextView/CodeEditTextView.swift rename to Sources/CodeEditSourceEditor/CodeEditSourceEditor.swift index 79d9b2c98..7308efa6c 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -1,15 +1,15 @@ // -// CodeEditTextView.swift -// CodeEditTextView +// CodeEditSourceEditor.swift +// CodeEditSourceEditor // // Created by Lukas Pistrol on 24.05.22. // import SwiftUI -import CodeEditInputView +import CodeEditTextView import CodeEditLanguages -public struct CodeEditTextView: NSViewControllerRepresentable { +public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// Initializes a Text Editor /// - Parameters: @@ -211,12 +211,12 @@ public struct CodeEditTextView: NSViewControllerRepresentable { @MainActor public class Coordinator: NSObject { - var parent: CodeEditTextView + var parent: CodeEditSourceEditor var controller: TextViewController? var isUpdatingFromRepresentable: Bool = false var isUpdateFromTextView: Bool = false - init(parent: CodeEditTextView) { + init(parent: CodeEditSourceEditor) { self.parent = parent super.init() @@ -270,3 +270,35 @@ public struct CodeEditTextView: NSViewControllerRepresentable { } } } + +// swiftlint:disable:next line_length +@available(*, unavailable, renamed: "CodeEditSourceEditor", message: "CodeEditTextView has been renamed to CodeEditSourceEditor.") +public struct CodeEditTextView: View { + public init( + _ text: Binding, + language: CodeLanguage, + theme: EditorTheme, + font: NSFont, + tabWidth: Int, + indentOption: IndentOption = .spaces(count: 4), + lineHeight: Double, + wrapLines: Bool, + editorOverscroll: CGFloat = 0, + cursorPositions: Binding<[CursorPosition]>, + useThemeBackground: Bool = true, + highlightProvider: HighlightProviding? = nil, + contentInsets: NSEdgeInsets? = nil, + isEditable: Bool = true, + isSelectable: Bool = true, + letterSpacing: Double = 1.0, + bracketPairHighlight: BracketPairHighlight? = nil, + undoManager: CEUndoManager? = nil, + coordinators: [any TextViewCoordinator] = [] + ) { + + } + + public var body: some View { + EmptyView() + } +} diff --git a/Sources/CodeEditTextView/Controller/CursorPosition.swift b/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift similarity index 94% rename from Sources/CodeEditTextView/Controller/CursorPosition.swift rename to Sources/CodeEditSourceEditor/Controller/CursorPosition.swift index a399626e2..03c13b4bb 100644 --- a/Sources/CodeEditTextView/Controller/CursorPosition.swift +++ b/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift @@ -1,6 +1,6 @@ // // CursorPosition.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 11/13/23. // @@ -20,7 +20,7 @@ public struct CursorPosition: Sendable, Codable, Equatable { /// Initialize a cursor position. /// /// When this initializer is used, ``CursorPosition/range`` will be initialized to `NSNotFound`. - /// The range value, however, be filled when updated by ``CodeEditTextView`` via a `Binding`, or when it appears + /// The range value, however, be filled when updated by ``CodeEditSourceEditor`` via a `Binding`, or when it appears /// in the``TextViewController/cursorPositions`` array. /// /// - Parameters: @@ -35,7 +35,7 @@ public struct CursorPosition: Sendable, Codable, Equatable { /// Initialize a cursor position. /// /// When this initializer is used, both ``CursorPosition/line`` and ``CursorPosition/column`` will be initialized - /// to `-1`. They will, however, be filled when updated by ``CodeEditTextView`` via a `Binding`, or when it + /// to `-1`. They will, however, be filled when updated by ``CodeEditSourceEditor`` via a `Binding`, or when it /// appears in the ``TextViewController/cursorPositions`` array. /// /// - Parameter range: The range of the cursor position. diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift similarity index 91% rename from Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index b1e977535..e66a22b92 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -1,6 +1,6 @@ // // TextViewController+Cursor.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Elias Wahl on 15.03.23. // @@ -26,10 +26,7 @@ extension TextViewController { newSelectedRanges.append(NSRange(location: 0, length: 0)) } else if let linePosition = textView.layoutManager.textLineForIndex(line - 1) { // If this is a valid line, set the new position - let index = max( - linePosition.range.lowerBound, - min(linePosition.range.upperBound, column - 1) - ) + let index = linePosition.range.lowerBound + min(linePosition.range.upperBound, column - 1) newSelectedRanges.append(NSRange(location: index, length: 0)) } } else { diff --git a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift similarity index 99% rename from Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift index 16c8e4958..86851f080 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift @@ -1,6 +1,6 @@ // // TextViewController+HighlightRange.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 4/26/23. // diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift similarity index 98% rename from Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift index 1f1c1201f..e56a2292a 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -1,6 +1,6 @@ // // TextViewController+Highlighter.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 10/14/23. // diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift similarity index 98% rename from Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 668f05a4b..ff39825cb 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -1,11 +1,11 @@ // // TextViewController+LoadView.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 10/14/23. // -import CodeEditInputView +import CodeEditTextView import AppKit extension TextViewController { diff --git a/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift similarity index 98% rename from Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 07f376662..297717895 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -1,12 +1,12 @@ // // TextViewController+TextFormation.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 1/26/23. // import AppKit -import CodeEditInputView +import CodeEditTextView import TextFormation import TextStory diff --git a/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift similarity index 93% rename from Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift index 4707af07b..885671fb8 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift @@ -1,12 +1,12 @@ // // TextViewController+TextViewDelegate.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 10/14/23. // import Foundation -import CodeEditInputView +import CodeEditTextView import TextStory extension TextViewController: TextViewDelegate { diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift similarity index 98% rename from Sources/CodeEditTextView/Controller/TextViewController.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 6c3bfe25e..69d4e59ca 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -1,12 +1,12 @@ // // TextViewController.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 6/25/23. // import AppKit -import CodeEditInputView +import CodeEditTextView import CodeEditLanguages import SwiftUI import Combine @@ -14,7 +14,7 @@ import TextFormation /// # TextViewController /// -/// A view controller class for managing a source editor. Uses ``CodeEditInputView/TextView`` for input and rendering, +/// A view controller class for managing a source editor. Uses ``CodeEditTextView/TextView`` for input and rendering, /// tree-sitter for syntax highlighting, and TextFormation for live editing completions. /// public class TextViewController: NSViewController { diff --git a/Sources/CodeEditTextView/Documentation.docc/CodeEditTextView.md b/Sources/CodeEditSourceEditor/Documentation.docc/CodeEditTextView.md similarity index 84% rename from Sources/CodeEditTextView/Documentation.docc/CodeEditTextView.md rename to Sources/CodeEditSourceEditor/Documentation.docc/CodeEditTextView.md index c8512125b..64826f3bb 100644 --- a/Sources/CodeEditTextView/Documentation.docc/CodeEditTextView.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/CodeEditTextView.md @@ -1,9 +1,9 @@ -# ``CodeEditTextView/CodeEditTextView`` +# ``CodeEditSourceEditor/CodeEditSourceEditor`` ## Usage ```swift -import CodeEditTextView +import CodeEditSourceEditor struct ContentView: View { @@ -15,7 +15,7 @@ struct ContentView: View { @State var editorOverscroll = 0.3 var body: some View { - CodeEditTextView( + CodeEditSourceEditor( $text, language: .swift, theme: $theme, diff --git a/Sources/CodeEditTextView/Documentation.docc/Documentation.md b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md similarity index 80% rename from Sources/CodeEditTextView/Documentation.docc/Documentation.md rename to Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md index 8fbe7adf0..7b86601cd 100644 --- a/Sources/CodeEditTextView/Documentation.docc/Documentation.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md @@ -1,4 +1,4 @@ -# ``CodeEditTextView`` +# ``CodeEditSourceEditor`` A code editor with syntax highlighting powered by tree-sitter. @@ -14,7 +14,7 @@ This package includes both `AppKit` and `SwiftUI` components. It also relies on ## Syntax Highlighting -``CodeEditTextView`` uses `tree-sitter` for syntax highlighting. A list of already supported languages can be found [here](https://github.com/CodeEditApp/CodeEditTextView/issues/15). +``CodeEditSourceEditor`` uses `tree-sitter` for syntax highlighting. A list of already supported languages can be found [here](https://github.com/CodeEditApp/CodeEditSourceEditor/issues/15). New languages need to be added to the [CodeEditLanguages](https://github.com/CodeEditApp/CodeEditLanguages) repo. @@ -31,8 +31,8 @@ Special thanks to both [Marcin Krzyzanowski](https://twitter.com/krzyzanowskim) ### Text View -- ``CodeEditTextView/CodeEditTextView`` -- ``CodeEditTextView/STTextViewController`` +- ``CodeEditSourceEditor/CodeEditSourceEditor`` +- ``CodeEditSourceEditor/TextViewController`` ### Theme diff --git a/Sources/CodeEditTextView/Documentation.docc/Resources/codeedittextview-logo.png b/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeedittextview-logo.png similarity index 100% rename from Sources/CodeEditTextView/Documentation.docc/Resources/codeedittextview-logo.png rename to Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeedittextview-logo.png diff --git a/Sources/CodeEditTextView/Documentation.docc/Resources/preview.png b/Sources/CodeEditSourceEditor/Documentation.docc/Resources/preview.png similarity index 100% rename from Sources/CodeEditTextView/Documentation.docc/Resources/preview.png rename to Sources/CodeEditSourceEditor/Documentation.docc/Resources/preview.png diff --git a/Sources/CodeEditTextView/Enums/BracketPairHighlight.swift b/Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift similarity index 98% rename from Sources/CodeEditTextView/Enums/BracketPairHighlight.swift rename to Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift index 026abf539..3a20fedd2 100644 --- a/Sources/CodeEditTextView/Enums/BracketPairHighlight.swift +++ b/Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift @@ -1,6 +1,6 @@ // // BracketPairHighlight.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 5/3/23. // diff --git a/Sources/CodeEditTextView/Enums/CaptureName.swift b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift similarity index 97% rename from Sources/CodeEditTextView/Enums/CaptureName.swift rename to Sources/CodeEditSourceEditor/Enums/CaptureName.swift index 0112782a8..941d1b6a5 100644 --- a/Sources/CodeEditTextView/Enums/CaptureName.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift @@ -1,6 +1,6 @@ // // CaptureNames.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Lukas Pistrol on 16.08.22. // diff --git a/Sources/CodeEditTextView/Enums/IndentOption.swift b/Sources/CodeEditSourceEditor/Enums/IndentOption.swift similarity index 96% rename from Sources/CodeEditTextView/Enums/IndentOption.swift rename to Sources/CodeEditSourceEditor/Enums/IndentOption.swift index 4904ba135..274fe5f0c 100644 --- a/Sources/CodeEditTextView/Enums/IndentOption.swift +++ b/Sources/CodeEditSourceEditor/Enums/IndentOption.swift @@ -1,6 +1,6 @@ // // IndentOption.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 3/26/23. // diff --git a/Sources/CodeEditTextView/Extensions/Color+Hex.swift b/Sources/CodeEditSourceEditor/Extensions/Color+Hex.swift similarity index 99% rename from Sources/CodeEditTextView/Extensions/Color+Hex.swift rename to Sources/CodeEditSourceEditor/Extensions/Color+Hex.swift index 972795c56..a231845c6 100644 --- a/Sources/CodeEditTextView/Extensions/Color+Hex.swift +++ b/Sources/CodeEditSourceEditor/Extensions/Color+Hex.swift @@ -1,6 +1,6 @@ // // Color+HEX.swift -// CodeEditTextView/Theme +// CodeEditSourceEditor // // Created by Lukas Pistrol on 27.05.22. // diff --git a/Sources/CodeEditTextView/Extensions/HighlighterTextView+createReadBlock.swift b/Sources/CodeEditSourceEditor/Extensions/HighlighterTextView+createReadBlock.swift similarity index 96% rename from Sources/CodeEditTextView/Extensions/HighlighterTextView+createReadBlock.swift rename to Sources/CodeEditSourceEditor/Extensions/HighlighterTextView+createReadBlock.swift index 54efa8161..ac5976d0a 100644 --- a/Sources/CodeEditTextView/Extensions/HighlighterTextView+createReadBlock.swift +++ b/Sources/CodeEditSourceEditor/Extensions/HighlighterTextView+createReadBlock.swift @@ -1,6 +1,6 @@ // // HighlighterTextView+createReadBlock.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 5/20/23. // diff --git a/Sources/CodeEditTextView/Extensions/IndexSet+NSRange.swift b/Sources/CodeEditSourceEditor/Extensions/IndexSet+NSRange.swift similarity index 97% rename from Sources/CodeEditTextView/Extensions/IndexSet+NSRange.swift rename to Sources/CodeEditSourceEditor/Extensions/IndexSet+NSRange.swift index f270e6463..b00417296 100644 --- a/Sources/CodeEditTextView/Extensions/IndexSet+NSRange.swift +++ b/Sources/CodeEditSourceEditor/Extensions/IndexSet+NSRange.swift @@ -1,6 +1,6 @@ // // IndexSet+NSRange.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 1/12/23. // diff --git a/Sources/CodeEditTextView/Extensions/NSEdgeInsets+Equatable.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift similarity index 93% rename from Sources/CodeEditTextView/Extensions/NSEdgeInsets+Equatable.swift rename to Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift index cb1607efa..dbdcdca23 100644 --- a/Sources/CodeEditTextView/Extensions/NSEdgeInsets+Equatable.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift @@ -1,6 +1,6 @@ // // NSEdgeInsets+Equatable.swift -// +// CodeEditSourceEditor // // Created by Wouter Hennen on 29/04/2023. // diff --git a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift similarity index 84% rename from Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift index 18f9347c4..a22957736 100644 --- a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift @@ -1,12 +1,12 @@ // // NSFont+LineHeight.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Lukas Pistrol on 28.05.22. // import AppKit -import CodeEditInputView +import CodeEditTextView public extension NSFont { /// The default line height of the font. diff --git a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift similarity index 98% rename from Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift index feae2e564..0221f3361 100644 --- a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift @@ -1,6 +1,6 @@ // // NSFont+RulerFont.swift -// +// CodeEditSourceEditor // // Created by Elias Wahl on 17.03.23. // diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift similarity index 93% rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift rename to Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift index 15c7e3b12..7ba206c24 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift @@ -1,6 +1,6 @@ // // NSRange+Comparable.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 3/15/23. // diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+InputEdit.swift similarity index 98% rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift rename to Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+InputEdit.swift index ee8018de5..7252541e1 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+InputEdit.swift @@ -1,6 +1,6 @@ // // NSRange+InputEdit.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 9/12/22. // diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+NSTextRange.swift b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+NSTextRange.swift similarity index 97% rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+NSTextRange.swift rename to Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+NSTextRange.swift index daaee99c1..d87d1567d 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+NSTextRange.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+NSTextRange.swift @@ -1,6 +1,6 @@ // // NSRange+NSTextRange.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 9/13/22. // diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+String.swift b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+String.swift similarity index 95% rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+String.swift rename to Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+String.swift index 589b794e2..c5f30f167 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+String.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+String.swift @@ -1,6 +1,6 @@ // // String+NSRange.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Lukas Pistrol on 25.05.22. // diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+TSRange.swift similarity index 93% rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift rename to Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+TSRange.swift index a0273784e..98412461e 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+TSRange.swift @@ -1,6 +1,6 @@ // // NSRange+TSRange.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 2/26/23. // diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+isEmpty.swift similarity index 86% rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift rename to Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+isEmpty.swift index d06be1df2..dc882a176 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+isEmpty.swift @@ -1,6 +1,6 @@ // // NSRange+isEmpty.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 10/14/23. // diff --git a/Sources/CodeEditTextView/Extensions/Parser+createTree.swift b/Sources/CodeEditSourceEditor/Extensions/Parser+createTree.swift similarity index 95% rename from Sources/CodeEditTextView/Extensions/Parser+createTree.swift rename to Sources/CodeEditSourceEditor/Extensions/Parser+createTree.swift index 6b84d70d5..514c5b5d7 100644 --- a/Sources/CodeEditTextView/Extensions/Parser+createTree.swift +++ b/Sources/CodeEditSourceEditor/Extensions/Parser+createTree.swift @@ -1,6 +1,6 @@ // // Parser+createTree.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 5/20/23. // diff --git a/Sources/CodeEditTextView/Extensions/String+encoding.swift b/Sources/CodeEditSourceEditor/Extensions/String+encoding.swift similarity index 93% rename from Sources/CodeEditTextView/Extensions/String+encoding.swift rename to Sources/CodeEditSourceEditor/Extensions/String+encoding.swift index b371f4763..9901433ec 100644 --- a/Sources/CodeEditTextView/Extensions/String+encoding.swift +++ b/Sources/CodeEditSourceEditor/Extensions/String+encoding.swift @@ -1,6 +1,6 @@ // // String+encoding.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 1/19/23. // diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+Menu.swift similarity index 98% rename from Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift rename to Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+Menu.swift index 2baa4b200..4f244faeb 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+Menu.swift @@ -1,12 +1,12 @@ // // TextView+Menu.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Lukas Pistrol on 25.05.22. // import AppKit -import CodeEditInputView +import CodeEditTextView extension TextView { diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift similarity index 96% rename from Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift rename to Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index a1e5cb800..6af20638e 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -1,12 +1,12 @@ // // TextView+TextFormation.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 10/14/23. // import Foundation -import CodeEditInputView +import CodeEditTextView import TextStory import TextFormation diff --git a/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift b/Sources/CodeEditSourceEditor/Extensions/Tree+prettyPrint.swift similarity index 98% rename from Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift rename to Sources/CodeEditSourceEditor/Extensions/Tree+prettyPrint.swift index a021733c9..ff27a1dbd 100644 --- a/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift +++ b/Sources/CodeEditSourceEditor/Extensions/Tree+prettyPrint.swift @@ -1,6 +1,6 @@ // // Tree+prettyPrint.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 3/16/23. // diff --git a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift b/Sources/CodeEditSourceEditor/Filters/DeleteWhitespaceFilter.swift similarity index 97% rename from Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift rename to Sources/CodeEditSourceEditor/Filters/DeleteWhitespaceFilter.swift index bfac27860..8e7aced20 100644 --- a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift +++ b/Sources/CodeEditSourceEditor/Filters/DeleteWhitespaceFilter.swift @@ -1,12 +1,12 @@ // // DeleteWhitespaceFilter.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 1/28/23. // import Foundation -import CodeEditInputView +import CodeEditTextView import TextFormation import TextStory diff --git a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift b/Sources/CodeEditSourceEditor/Filters/TabReplacementFilter.swift similarity index 97% rename from Sources/CodeEditTextView/Filters/TabReplacementFilter.swift rename to Sources/CodeEditSourceEditor/Filters/TabReplacementFilter.swift index 7260bb343..c61c20089 100644 --- a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift +++ b/Sources/CodeEditSourceEditor/Filters/TabReplacementFilter.swift @@ -1,6 +1,6 @@ // // TabReplacementFilter.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 1/28/23. // diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift similarity index 99% rename from Sources/CodeEditTextView/Gutter/GutterView.swift rename to Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 6d02aade4..2389b9926 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -1,12 +1,12 @@ // // GutterView.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 8/22/23. // import AppKit -import CodeEditInputView +import CodeEditTextView public protocol GutterViewDelegate: AnyObject { func gutterViewWidthDidUpdate(newWidth: CGFloat) diff --git a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding.swift similarity index 98% rename from Sources/CodeEditTextView/Highlighting/HighlightProviding.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlightProviding.swift index 9ac761987..ec8fe89f1 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding.swift @@ -1,6 +1,6 @@ // // HighlightProviding.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 1/18/23. // diff --git a/Sources/CodeEditTextView/Highlighting/HighlightRange.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift similarity index 93% rename from Sources/CodeEditTextView/Highlighting/HighlightRange.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift index ea6cc2fd5..710e206f0 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlightRange.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift @@ -1,6 +1,6 @@ // // HighlightRange.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 9/14/22. // diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift similarity index 99% rename from Sources/CodeEditTextView/Highlighting/Highlighter.swift rename to Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index a2485d7e3..9277b16d5 100644 --- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -1,13 +1,13 @@ // // Highlighter.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 9/12/22. // import Foundation import AppKit -import CodeEditInputView +import CodeEditTextView import SwiftTreeSitter import CodeEditLanguages diff --git a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighterTextView.swift similarity index 92% rename from Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlighterTextView.swift index 44ce505d3..ed8b4e355 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighterTextView.swift @@ -1,13 +1,13 @@ // // HighlighterTextView.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 1/26/23. // import Foundation import AppKit -import CodeEditInputView +import CodeEditTextView /// The object `HighlightProviding` objects are given when asked for highlights. public protocol HighlighterTextView: AnyObject { diff --git a/Sources/CodeEditTextView/TextViewCoordinator.swift b/Sources/CodeEditSourceEditor/TextViewCoordinator.swift similarity index 89% rename from Sources/CodeEditTextView/TextViewCoordinator.swift rename to Sources/CodeEditSourceEditor/TextViewCoordinator.swift index c59685d2e..98c553cd5 100644 --- a/Sources/CodeEditTextView/TextViewCoordinator.swift +++ b/Sources/CodeEditSourceEditor/TextViewCoordinator.swift @@ -1,24 +1,23 @@ // // TextViewCoordinator.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 11/14/23. // import AppKit -import CodeEditInputView /// # TextViewCoordinator /// -/// A protocol that can be used to provide extra functionality to ``CodeEditTextView/CodeEditTextView`` while avoiding -/// some of the inefficiencies of SwiftUI. +/// A protocol that can be used to provide extra functionality to ``CodeEditSourceEditor/CodeEditSourceEditor`` while +/// avoiding some of the inefficiencies of SwiftUI. /// public protocol TextViewCoordinator: AnyObject { /// Called when an instance of ``TextViewController`` is available. Use this method to install any delegates, /// perform any modifications on the text view or controller, or capture the text view for later use in your app. /// /// - Parameter controller: The text controller. This is safe to keep a weak reference to, as long as it is - /// dereferenced when ``TextViewCoordinator/destroy()-49rej`` is called. + /// dereferenced when ``TextViewCoordinator/destroy()-9nzfl`` is called. func prepareCoordinator(controller: TextViewController) /// Called when the text view's text changed. diff --git a/Sources/CodeEditTextView/Theme/EditorTheme.swift b/Sources/CodeEditSourceEditor/Theme/EditorTheme.swift similarity index 99% rename from Sources/CodeEditTextView/Theme/EditorTheme.swift rename to Sources/CodeEditSourceEditor/Theme/EditorTheme.swift index 39e1be9ce..9118fb2b0 100644 --- a/Sources/CodeEditTextView/Theme/EditorTheme.swift +++ b/Sources/CodeEditSourceEditor/Theme/EditorTheme.swift @@ -1,6 +1,6 @@ // // EditorTheme.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Lukas Pistrol on 29.05.22. // diff --git a/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift b/Sources/CodeEditSourceEditor/Theme/ThemeAttributesProviding.swift similarity index 93% rename from Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift rename to Sources/CodeEditSourceEditor/Theme/ThemeAttributesProviding.swift index d1a32f884..7935943d5 100644 --- a/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift +++ b/Sources/CodeEditSourceEditor/Theme/ThemeAttributesProviding.swift @@ -1,6 +1,6 @@ // // ThemeAttributesProviding.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 1/18/23. // diff --git a/Sources/CodeEditTextView/TreeSitter/LanguageLayer.swift b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift similarity index 99% rename from Sources/CodeEditTextView/TreeSitter/LanguageLayer.swift rename to Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift index 0c12c688b..8054a6290 100644 --- a/Sources/CodeEditTextView/TreeSitter/LanguageLayer.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift @@ -1,6 +1,6 @@ // // TreeSitterClient+LanguageLayer.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 3/8/23. // diff --git a/Sources/CodeEditTextView/TreeSitter/PthreadLock.swift b/Sources/CodeEditSourceEditor/TreeSitter/PthreadLock.swift similarity index 96% rename from Sources/CodeEditTextView/TreeSitter/PthreadLock.swift rename to Sources/CodeEditSourceEditor/TreeSitter/PthreadLock.swift index 739fb5c8a..c34e3fd42 100644 --- a/Sources/CodeEditTextView/TreeSitter/PthreadLock.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/PthreadLock.swift @@ -1,6 +1,6 @@ // // PthreadLock.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 6/2/23. // diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Edit.swift similarity index 100% rename from Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift rename to Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Edit.swift diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Highlight.swift similarity index 99% rename from Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift rename to Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Highlight.swift index 2f7726c38..fc0fd36ae 100644 --- a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Highlight.swift @@ -1,6 +1,6 @@ // // TreeSitterClient+Highlight.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 3/10/23. // diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift similarity index 99% rename from Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift rename to Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift index 7a92eaa03..1f8c94098 100644 --- a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift @@ -1,6 +1,6 @@ // // TreeSitterClient.swift -// +// CodeEditSourceEditor // // Created by Khan Winter on 9/12/22. // diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterState.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterState.swift similarity index 99% rename from Sources/CodeEditTextView/TreeSitter/TreeSitterState.swift rename to Sources/CodeEditSourceEditor/TreeSitter/TreeSitterState.swift index e48b9b019..2aa24ae03 100644 --- a/Sources/CodeEditTextView/TreeSitter/TreeSitterState.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterState.swift @@ -1,6 +1,6 @@ // // TreeSitterState.swift -// CodeEditTextView +// CodeEditSourceEditor // // Created by Khan Winter on 5/21/23. // diff --git a/Sources/CodeEditTextView/Documentation.docc/STTextViewController.md b/Sources/CodeEditTextView/Documentation.docc/STTextViewController.md deleted file mode 100644 index 19d1c7909..000000000 --- a/Sources/CodeEditTextView/Documentation.docc/STTextViewController.md +++ /dev/null @@ -1,13 +0,0 @@ -# ``CodeEditTextView/STTextViewController`` - -## Topics - -### Instance Properties - -- ``text`` -- ``theme`` -- ``language`` -- ``font`` -- ``tabWidth`` -- ``lineHeightMultiple`` -- ``editorOverscroll`` diff --git a/Tests/CodeEditInputViewTests/LineEndingTests.swift b/Tests/CodeEditInputViewTests/LineEndingTests.swift deleted file mode 100644 index 211f5c843..000000000 --- a/Tests/CodeEditInputViewTests/LineEndingTests.swift +++ /dev/null @@ -1,92 +0,0 @@ -import XCTest -@testable import CodeEditInputView - -// swiftlint:disable all - -class LineEndingTests: XCTestCase { - func test_lineEndingCreateUnix() { - // The \n character - XCTAssertTrue(LineEnding(rawValue: "\n") != nil, "Line ending failed to initialize with the \\n character") - - let line = "Loren Ipsum\n" - XCTAssertTrue(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\n") - } - - func test_lineEndingCreateCRLF() { - // The \r\n sequence - XCTAssertTrue(LineEnding(rawValue: "\r\n") != nil, "Line ending failed to initialize with the \\r\\n sequence") - - let line = "Loren Ipsum\r\n" - XCTAssertTrue(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\r\\n") - } - - func test_lineEndingCreateMacOS() { - // The \r character - XCTAssertTrue(LineEnding(rawValue: "\r") != nil, "Line ending failed to initialize with the \\r character") - - let line = "Loren Ipsum\r" - XCTAssertTrue(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\r") - } - - func test_detectLineEndingDefault() { - // There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not flaky. - // The odds of it being bad with the earlier bug after running 20 times is incredibly small - for _ in 0..<20 { - let storage = NSTextStorage(string: "hello world") // No line ending - let lineStorage = TextLineStorage() - lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) - let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == .lineFeed, "Default detected line ending incorrect, expected: \n, got: \(detected.rawValue.debugDescription)") - } - } - - func test_detectLineEndingUnix() { - let corpus = "abcdefghijklmnopqrstuvwxyz123456789" - let goalLineEnding = LineEnding.lineFeed - - let text = (10..() - lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) - - let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") - } - - func test_detectLineEndingCLRF() { - let corpus = "abcdefghijklmnopqrstuvwxyz123456789" - let goalLineEnding = LineEnding.carriageReturnLineFeed - - let text = (10..() - lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) - - let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") - } - - func test_detectLineEndingMacOS() { - let corpus = "abcdefghijklmnopqrstuvwxyz123456789" - let goalLineEnding = LineEnding.carriageReturn - - let text = (10..() - lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) - - let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") - } -} - -// swiftlint:enable all diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift deleted file mode 100644 index 3c37db746..000000000 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ /dev/null @@ -1,278 +0,0 @@ -import XCTest -@testable import CodeEditInputView - -// swiftlint:disable function_body_length - -fileprivate extension CGFloat { - func approxEqual(_ value: CGFloat) -> Bool { - return abs(self - value) < 0.05 - } -} - -final class TextLayoutLineStorageTests: XCTestCase { - /// Creates a balanced height=3 tree useful for testing and debugging. - /// - Returns: A new tree. - fileprivate func createBalancedTree() -> TextLineStorage { - let tree = TextLineStorage() - var data = [TextLineStorage.BuildItem]() - for idx in 0..<15 { - data.append(.init(data: TextLine(), length: idx + 1, height: 1.0)) - } - tree.build(from: data, estimatedLineHeight: 1.0) - return tree - } - - /// 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 { - guard let node else { return ChildData(length: 0, count: 0, height: 0.0) } - let leftSubtreeData = checkChildren(node.left) - let rightSubtreeData = checkChildren(node.right) - - XCTAssert(leftSubtreeData.length == node.leftSubtreeOffset, "Left subtree length incorrect") - XCTAssert(leftSubtreeData.count == node.leftSubtreeCount, "Left subtree node count incorrect") - XCTAssert(leftSubtreeData.height.approxEqual(node.leftSubtreeHeight), "Left subtree height incorrect") - - return ChildData( - length: node.length + leftSubtreeData.length + rightSubtreeData.length, - count: 1 + leftSubtreeData.count + rightSubtreeData.count, - height: node.height + leftSubtreeData.height + rightSubtreeData.height - ) - } - - let rootData = checkChildren(tree.root) - - XCTAssert(rootData.count == tree.count, "Node count incorrect") - XCTAssert(rootData.length == tree.length, "Length incorrect") - XCTAssert(rootData.height.approxEqual(tree.height), "Height incorrect") - - var lastIdx = -1 - for line in tree { - XCTAssert(lastIdx == line.index - 1, "Incorrect index found") - lastIdx = line.index - } - } - - func test_insert() throws { - var tree = TextLineStorage() - - // Single Element - tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 50.0) - XCTAssert(tree.length == 1, "Tree length incorrect") - XCTAssert(tree.count == 1, "Tree count incorrect") - XCTAssert(tree.height == 50.0, "Tree height incorrect") - XCTAssert(tree.root?.right == nil && tree.root?.left == nil, "Somehow inserted an extra node.") - try assertTreeMetadataCorrect(tree) - - // Insert into first - tree = createBalancedTree() - tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) - try assertTreeMetadataCorrect(tree) - - // Insert into last - tree = createBalancedTree() - tree.insert(line: TextLine(), atOffset: tree.length - 1, length: 1, height: 1.0) - try assertTreeMetadataCorrect(tree) - - tree = createBalancedTree() - tree.insert(line: TextLine(), atOffset: 45, length: 1, height: 1.0) - try assertTreeMetadataCorrect(tree) - } - - func test_update() throws { - var tree = TextLineStorage() - - // Single Element - tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) - tree.update(atIndex: 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") - XCTAssert(tree.root?.right == nil && tree.root?.left == nil, "Somehow inserted an extra node.") - try assertTreeMetadataCorrect(tree) - - // Update First - tree = createBalancedTree() - tree.update(atIndex: 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") - XCTAssertEqual(tree.first?.range.length, 13, "First node wasn't updated correctly.") - try assertTreeMetadataCorrect(tree) - - // Update Last - tree = createBalancedTree() - tree.update(atIndex: 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") - XCTAssertEqual(tree.last?.range.length, 1, "Last node wasn't updated correctly.") - try assertTreeMetadataCorrect(tree) - - // Update middle - tree = createBalancedTree() - tree.update(atIndex: 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") - XCTAssert(tree.root?.right?.left?.height == 2.0 && tree.root?.right?.left?.length == 1, "Node wasn't updated") - try assertTreeMetadataCorrect(tree) - - // Update at random - tree = createBalancedTree() - for _ in 0..<20 { - let delta = Int.random(in: 1..<20) - let deltaHeight = Double.random(in: 0..<20.0) - let originalHeight = tree.height - let originalCount = tree.count - let originalLength = tree.length - tree.update(atIndex: Int.random(in: 0..() - - // Single Element - tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) - XCTAssert(tree.length == 1, "Tree length incorrect") - tree.delete(lineAt: 0) - XCTAssert(tree.length == 0, "Tree failed to delete single node") - XCTAssert(tree.root == nil, "Tree root should be nil") - try assertTreeMetadataCorrect(tree) - - // Delete first - - tree = createBalancedTree() - tree.delete(lineAt: 0) - XCTAssert(tree.count == 14, "Tree length incorrect") - XCTAssert(tree.first?.range.length == 2, "Failed to delete leftmost node") - try assertTreeMetadataCorrect(tree) - - // Delete last - - tree = createBalancedTree() - tree.delete(lineAt: tree.length - 1) - XCTAssert(tree.count == 14, "Tree length incorrect") - XCTAssert(tree.last?.range.length == 14, "Failed to delete rightmost node") - try assertTreeMetadataCorrect(tree) - - // Delete mid leaf - - tree = createBalancedTree() - tree.delete(lineAt: 45) - XCTAssert(tree.root?.right?.left?.length == 11, "Failed to remove node 10") - XCTAssert(tree.root?.right?.leftSubtreeOffset == 20, "Failed to update metadata on parent of node 10") - XCTAssert(tree.root?.right?.left?.right == nil, "Failed to replace node 10 with node 11") - XCTAssert(tree.count == 14, "Tree length incorrect") - try assertTreeMetadataCorrect(tree) - - tree = createBalancedTree() - tree.delete(lineAt: 66) - XCTAssert(tree.root?.right?.length == 13, "Failed to remove node 12") - XCTAssert(tree.root?.right?.leftSubtreeOffset == 30, "Failed to update metadata on parent of node 13") - XCTAssert(tree.root?.right?.left?.right?.left == nil, "Failed to replace node 12 with node 13") - XCTAssert(tree.count == 14, "Tree length incorrect") - try assertTreeMetadataCorrect(tree) - - // Delete root - - tree = createBalancedTree() - tree.delete(lineAt: tree.root!.leftSubtreeOffset + 1) - XCTAssert(tree.root?.color == .black, "Root color incorrect") - XCTAssert(tree.root?.right?.left?.left == nil, "Replacement node was not moved to root") - XCTAssert(tree.root?.leftSubtreeCount == 7, "Replacement node was not given correct metadata.") - XCTAssert(tree.root?.leftSubtreeHeight == 7.0, "Replacement node was not given correct metadata.") - XCTAssert(tree.root?.leftSubtreeOffset == 28, "Replacement node was not given correct metadata.") - XCTAssert(tree.count == 14, "Tree length incorrect") - try assertTreeMetadataCorrect(tree) - - // Delete a bunch of random - - for _ in 0..<20 { - tree = createBalancedTree() - var lastCount = 15 - while !tree.isEmpty { - lastCount -= 1 - tree.delete(lineAt: Int.random(in: 0.. last, "Out of order after deletion") - last = line.range.length - } - try assertTreeMetadataCorrect(tree) - } - } - } - - func test_insertPerformance() { - let tree = TextLineStorage() - var lines: [TextLineStorage.BuildItem] = [] - for idx in 0..<250_000 { - lines.append(TextLineStorage.BuildItem( - data: TextLine(), - length: idx + 1, - height: 0.0 - )) - } - tree.build(from: lines, estimatedLineHeight: 1.0) - // Measure time when inserting randomly into an already built tree. - // Start 0.667s - // 10/6/23 0.563s -15.59% - measure { - for _ in 0..<100_000 { - tree.insert( - line: TextLine(), atOffset: Int.random(in: 0..() - let lines: [TextLineStorage.BuildItem] = (0..<250_000).map { - TextLineStorage.BuildItem( - data: TextLine(), - length: $0 + 1, - height: 0.0 - ) - } - // Start 0.113s - measure { - tree.build(from: lines, estimatedLineHeight: 1.0) - } - } - - func test_iterationPerformance() { - let tree = TextLineStorage() - var lines: [TextLineStorage.BuildItem] = [] - for idx in 0..<100_000 { - lines.append(TextLineStorage.BuildItem( - data: TextLine(), - length: idx + 1, - height: 0.0 - )) - } - tree.build(from: lines, estimatedLineHeight: 1.0) - // Start 0.181s - measure { - for line in tree { - _ = line - } - } - } -} - -// swiftlint:enable function_body_length diff --git a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift deleted file mode 100644 index bd4f623fa..000000000 --- a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift +++ /dev/null @@ -1,220 +0,0 @@ -import XCTest -@testable import CodeEditInputView - -final class TextSelectionManagerTests: XCTestCase { - var textStorage: NSTextStorage! - var layoutManager: TextLayoutManager! - - func selectionManager(_ text: String = "Loren Ipsum 💯") -> TextSelectionManager { - textStorage = NSTextStorage(string: text) - layoutManager = TextLayoutManager( - textStorage: textStorage, - lineHeightMultiplier: 1.0, - wrapLines: false, - textView: NSView(), - delegate: nil - ) - return TextSelectionManager( - layoutManager: layoutManager, - textStorage: textStorage, - layoutView: nil, - delegate: nil - ) - } - - func test_updateSelectionLeft() { - let selectionManager = selectionManager() - let locations = [2, 0, 14, 14] - let expectedRanges = [(1, 1), (0, 0), (12, 2), (13, 1)] - let decomposeCharacters = [false, false, false, true] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .backward, - destination: .character, - decomposeCharacters: decomposeCharacters[idx] - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } - - func test_updateSelectionRight() { - let selectionManager = selectionManager() - let locations = [2, 0, 14, 13, 12] - let expectedRanges = [(2, 1), (0, 1), (14, 0), (12, 2), (12, 1)] - let decomposeCharacters = [false, false, false, false, true] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .forward, - destination: .character, - decomposeCharacters: decomposeCharacters[idx] - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } - - func test_updateSelectionLeftWord() { - let selectionManager = selectionManager() - let locations = [2, 0, 12] - let expectedRanges = [(0, 2), (0, 0), (6, 6)] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .backward, - destination: .word, - decomposeCharacters: false - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } - - func test_updateSelectionRightWord() { - // "Loren Ipsum 💯" - let selectionManager = selectionManager() - let locations = [2, 0, 6] - let expectedRanges = [(2, 3), (0, 5), (6, 5)] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .forward, - destination: .word, - decomposeCharacters: false - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } - - func test_updateSelectionLeftLine() { - // "Loren Ipsum 💯" - let selectionManager = selectionManager() - let locations = [2, 0, 14, 12] - let expectedRanges = [(0, 2), (0, 0), (0, 14), (0, 12)] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .backward, - destination: .line, - decomposeCharacters: false - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } - - func test_updateSelectionRightLine() { - let selectionManager = selectionManager("Loren Ipsum 💯\nHello World") - let locations = [2, 0, 14, 12, 17] - let expectedRanges = [(2, 12), (0, 14), (14, 0), (12, 2), (17, 9)] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .forward, - destination: .line, - decomposeCharacters: false - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } - - func test_updateSelectionUpDocument() { - let selectionManager = selectionManager("Loren Ipsum 💯\nHello World\n1\n2\n3\n") - let locations = [0, 27, 30, 33] - let expectedRanges = [(0, 0), (0, 27), (0, 30), (0, 33)] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .up, - destination: .document, - decomposeCharacters: false - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } - - func test_updateSelectionDownDocument() { - let selectionManager = selectionManager("Loren Ipsum 💯\nHello World\n1\n2\n3\n") - let locations = [0, 2, 27, 30, 33] - let expectedRanges = [(0, 33), (2, 31), (27, 6), (30, 3), (33, 0)] - - for idx in locations.indices { - let range = selectionManager.rangeOfSelection( - from: locations[idx], - direction: .down, - destination: .document, - decomposeCharacters: false - ) - - XCTAssert( - range.location == expectedRanges[idx].0, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - XCTAssert( - range.length == expectedRanges[idx].1, - "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" - ) - } - } -} diff --git a/Tests/CodeEditTextViewTests/CodeEditTextViewTests.swift b/Tests/CodeEditSourceEditorTests/CodeEditSourceEditorTests.swift similarity index 92% rename from Tests/CodeEditTextViewTests/CodeEditTextViewTests.swift rename to Tests/CodeEditSourceEditorTests/CodeEditSourceEditorTests.swift index c79c20ca4..f9e2cfa2d 100644 --- a/Tests/CodeEditTextViewTests/CodeEditTextViewTests.swift +++ b/Tests/CodeEditSourceEditorTests/CodeEditSourceEditorTests.swift @@ -1,8 +1,8 @@ import XCTest -@testable import CodeEditTextView +@testable import CodeEditSourceEditor // swiftlint:disable all -final class CodeEditTextViewTests: XCTestCase { +final class CodeEditSourceEditorTests: XCTestCase { // MARK: NSFont Line Height diff --git a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift similarity index 99% rename from Tests/CodeEditTextViewTests/TextViewControllerTests.swift rename to Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index 7ae5c49fe..9c84bdb40 100644 --- a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeEditTextView +@testable import CodeEditSourceEditor import SwiftTreeSitter import AppKit import SwiftUI diff --git a/Tests/CodeEditTextViewTests/TreeSitterClientTests.swift b/Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift similarity index 99% rename from Tests/CodeEditTextViewTests/TreeSitterClientTests.swift rename to Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift index a12580492..13467e8a0 100644 --- a/Tests/CodeEditTextViewTests/TreeSitterClientTests.swift +++ b/Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeEditTextView +@testable import CodeEditSourceEditor // swiftlint:disable all fileprivate class TestTextView: HighlighterTextView {