diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme index e3a4330fd..3ac36c38c 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme @@ -52,6 +52,16 @@ ReferencedContainer = "container:"> + + + + - +

CodeEditTextView

@@ -67,11 +67,10 @@ See this issue https://github.com/CodeEditApp/CodeEditLanguages/issues/10 on `Co ## Dependencies -Special thanks to both [Marcin Krzyzanowski](https://twitter.com/krzyzanowskim) & [Matt Massicotte](https://twitter.com/mattie) for the great work they've done! +Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great work he's done! | Package | Source | Author | | :- | :- | :- | -| `STTextView` | [GitHub](https://github.com/krzyzanowskim/STTextView) | [Marcin Krzyzanowski](https://twitter.com/krzyzanowskim) | | `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://twitter.com/mattie) | ## License diff --git a/Sources/CodeEditInputView/Documentation.docc/Documentation.md b/Sources/CodeEditInputView/Documentation.docc/Documentation.md new file mode 100644 index 000000000..d7ffa9dd1 --- /dev/null +++ b/Sources/CodeEditInputView/Documentation.docc/Documentation.md @@ -0,0 +1,37 @@ +# ``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 new file mode 100644 index 000000000..8863c996c --- /dev/null +++ b/Sources/CodeEditInputView/Extensions/NSRange+isEmpty.swift @@ -0,0 +1,14 @@ +// +// 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 new file mode 100644 index 000000000..b7256d565 --- /dev/null +++ b/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift @@ -0,0 +1,28 @@ +// +// 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 new file mode 100644 index 000000000..80e501c99 --- /dev/null +++ b/Sources/CodeEditInputView/Extensions/PixelAligned.swift @@ -0,0 +1,22 @@ +// +// 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 new file mode 100644 index 000000000..09be2d6a7 --- /dev/null +++ b/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift @@ -0,0 +1,98 @@ +// +// 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 new file mode 100644 index 000000000..0b00d2c52 --- /dev/null +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -0,0 +1,113 @@ +// +// 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 new file mode 100644 index 000000000..40f6f738c --- /dev/null +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -0,0 +1,32 @@ +// +// 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 new file mode 100644 index 000000000..97af1d1ed --- /dev/null +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -0,0 +1,209 @@ +// +// 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 new file mode 100644 index 000000000..a7ae3cb63 --- /dev/null +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -0,0 +1,374 @@ +// +// 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 new file mode 100644 index 000000000..d64e0d93f --- /dev/null +++ b/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift @@ -0,0 +1,14 @@ +// +// 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 new file mode 100644 index 000000000..e8905b6ee --- /dev/null +++ b/Sources/CodeEditInputView/TextLine/LineFragment.swift @@ -0,0 +1,42 @@ +// +// 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 new file mode 100644 index 000000000..3d95eea6c --- /dev/null +++ b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift @@ -0,0 +1,52 @@ +// +// 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 new file mode 100644 index 000000000..977b9741d --- /dev/null +++ b/Sources/CodeEditInputView/TextLine/TextLine.swift @@ -0,0 +1,72 @@ +// +// 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 new file mode 100644 index 000000000..4f46965ba --- /dev/null +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -0,0 +1,217 @@ +// +// 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 new file mode 100644 index 000000000..34377bd4b --- /dev/null +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift @@ -0,0 +1,109 @@ +// +// 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 new file mode 100644 index 000000000..abf05b07e --- /dev/null +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift @@ -0,0 +1,35 @@ +// +// 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 new file mode 100644 index 000000000..745cfec3a --- /dev/null +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -0,0 +1,156 @@ +// +// 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 new file mode 100644 index 000000000..c98f3dd54 --- /dev/null +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift @@ -0,0 +1,74 @@ +// +// 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 new file mode 100644 index 000000000..273362364 --- /dev/null +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -0,0 +1,632 @@ +// +// 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 new file mode 100644 index 000000000..a519939b7 --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift @@ -0,0 +1,121 @@ +// +// 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 new file mode 100644 index 000000000..d4c45d53f --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -0,0 +1,102 @@ +// +// 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 new file mode 100644 index 000000000..e917f1730 --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift @@ -0,0 +1,212 @@ +// +// 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 new file mode 100644 index 000000000..d8c2c9ee6 --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -0,0 +1,399 @@ +// +// 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 new file mode 100644 index 000000000..f0b1fffe4 --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift @@ -0,0 +1,46 @@ +// +// 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 new file mode 100644 index 000000000..8f766ff8d --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -0,0 +1,278 @@ +// +// 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 new file mode 100644 index 000000000..aa4fe3153 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift @@ -0,0 +1,127 @@ +// +// 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 new file mode 100644 index 000000000..b89c25c2e --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift @@ -0,0 +1,35 @@ +// +// 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 new file mode 100644 index 000000000..6804a2375 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Delete.swift @@ -0,0 +1,65 @@ +// +// 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 new file mode 100644 index 000000000..171e0c4ab --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Drag.swift @@ -0,0 +1,79 @@ +// +// 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 new file mode 100644 index 000000000..8d87288e0 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Insert.swift @@ -0,0 +1,18 @@ +// +// 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 new file mode 100644 index 000000000..ef55705f4 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Menu.swift @@ -0,0 +1,21 @@ +// +// 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 new file mode 100644 index 000000000..77fee3682 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift @@ -0,0 +1,88 @@ +// +// 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 new file mode 100644 index 000000000..7beb92310 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -0,0 +1,161 @@ +// +// 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 new file mode 100644 index 000000000..a9274f329 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -0,0 +1,282 @@ +// +// 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 new file mode 100644 index 000000000..4e4b662d1 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift @@ -0,0 +1,54 @@ +// +// 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 new file mode 100644 index 000000000..bff72c237 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Select.swift @@ -0,0 +1,66 @@ +// +// 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 new file mode 100644 index 000000000..7eea47bff --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -0,0 +1,29 @@ +// +// 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 new file mode 100644 index 000000000..d13bd234d --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift @@ -0,0 +1,18 @@ +// +// 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 new file mode 100644 index 000000000..d89a635a7 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift @@ -0,0 +1,38 @@ +// +// 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 new file mode 100644 index 000000000..4e546d150 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift @@ -0,0 +1,31 @@ +// +// 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 new file mode 100644 index 000000000..e54274fd1 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -0,0 +1,499 @@ +// +// 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 new file mode 100644 index 000000000..b977408c4 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift @@ -0,0 +1,20 @@ +// +// 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/CodeEditTextView/Controller/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift similarity index 77% rename from Sources/CodeEditTextView/Controller/CEUndoManager.swift rename to Sources/CodeEditInputView/Utils/CEUndoManager.swift index 275a6af73..b65e033cf 100644 --- a/Sources/CodeEditTextView/Controller/CEUndoManager.swift +++ b/Sources/CodeEditInputView/Utils/CEUndoManager.swift @@ -5,7 +5,6 @@ // Created by Khan Winter on 7/8/23. // -import STTextView import AppKit import TextStory @@ -18,7 +17,7 @@ import TextStory /// 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 `STTextView` to use the `UndoManager` API + /// 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? @@ -47,17 +46,18 @@ public class CEUndoManager { /// Represents a group of mutations that should be treated as one mutation when undoing/redoing. private struct UndoGroup { - struct Mutation { - var mutation: TextMutation - var inverse: TextMutation - } - var mutations: [Mutation] } + /// A single undo mutation. + private struct Mutation { + var mutation: TextMutation + var inverse: TextMutation + } + public let manager: DelegatedUndoManager - public var isUndoing: Bool = false - public var isRedoing: Bool = false + private(set) public var isUndoing: Bool = false + private(set) public var isRedoing: Bool = false public var canUndo: Bool { !undoStack.isEmpty @@ -71,24 +71,34 @@ public class CEUndoManager { /// A stack of operations that can be redone. private var redoStack: [UndoGroup] = [] - internal weak var textView: STTextView? - private(set) var isGrouping: Bool = false + 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 let item = undoStack.popLast(), - let textView else { + 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.applyMutationNoUndo(mutation.inverse) + textView.insertText(mutation.inverse.string, replacementRange: mutation.inverse.range) NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager) } redoStack.append(item) @@ -97,14 +107,13 @@ public class CEUndoManager { /// Performs a redo operation if there is one available. public func redo() { - guard let item = redoStack.popLast(), - let textView else { + 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.applyMutationNoUndo(mutation.mutation) + textView.insertText(mutation.mutation.string, replacementRange: mutation.mutation.range) NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager) } undoStack.append(item) @@ -117,15 +126,20 @@ public class CEUndoManager { 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 else { return } - - if (mutation.range.length == 0 && mutation.string.isEmpty) || isUndoing || isRedoing { return } - let newMutation = UndoGroup.Mutation(mutation: mutation, inverse: textView.inverseMutation(for: mutation)) + 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) @@ -141,6 +155,8 @@ public class CEUndoManager { redoStack.removeAll() } + // MARK: - Grouping + /// Groups all incoming mutations. public func beginGrouping() { isGrouping = true @@ -163,7 +179,7 @@ public class CEUndoManager { /// - 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: UndoGroup.Mutation, lastMutation: UndoGroup.Mutation) -> Bool { + 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) { @@ -194,4 +210,18 @@ public class CEUndoManager { ) } } + + // 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 new file mode 100644 index 000000000..133bf7b39 --- /dev/null +++ b/Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift @@ -0,0 +1,32 @@ +// +// 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 new file mode 100644 index 000000000..2e5051d22 --- /dev/null +++ b/Sources/CodeEditInputView/Utils/LineEnding.swift @@ -0,0 +1,69 @@ +// +// 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 new file mode 100644 index 000000000..543b8e5d7 --- /dev/null +++ b/Sources/CodeEditInputView/Utils/Logger.swift @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..cf4bcf0ea --- /dev/null +++ b/Sources/CodeEditInputView/Utils/MultiStorageDelegate.swift @@ -0,0 +1,43 @@ +// +// 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 new file mode 100644 index 000000000..3a044d5ee --- /dev/null +++ b/Sources/CodeEditInputView/Utils/ViewReuseQueue.swift @@ -0,0 +1,73 @@ +// +// 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/CEScrollView.swift b/Sources/CodeEditTextView/CEScrollView.swift deleted file mode 100644 index c16514663..000000000 --- a/Sources/CodeEditTextView/CEScrollView.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// CEScrollView.swift -// -// -// Created by Renan Greca on 18/02/23. -// - -import AppKit -import STTextView - -class CEScrollView: NSScrollView { - - override open var contentSize: NSSize { - var proposedSize = super.contentSize - proposedSize.width -= verticalRulerView?.requiredThickness ?? 0.0 - return proposedSize - } - - override func mouseDown(with event: NSEvent) { - - if let textView = self.documentView as? STTextView, - !textView.visibleRect.contains(event.locationInWindow) { - // If the `scrollView` was clicked, but the click did not happen within the `textView`, - // set cursor to the last index of the `textView`. - - let endLocation = textView.textLayoutManager.documentRange.endLocation - let range = NSTextRange(location: endLocation) - _ = textView.becomeFirstResponder() - textView.setSelectedTextRange(range) - } - - super.mouseDown(with: event) - } -} diff --git a/Sources/CodeEditTextView/CETextView.swift b/Sources/CodeEditTextView/CETextView.swift deleted file mode 100644 index dfb363b9a..000000000 --- a/Sources/CodeEditTextView/CETextView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CETextView.swift -// CodeEditTextView -// -// Created by Khan Winter on 7/8/23. -// - -import AppKit -import UniformTypeIdentifiers -import TextStory -import STTextView - -class CETextView: STTextView { - override open func paste(_ sender: Any?) { - guard let undoManager = undoManager as? CEUndoManager.DelegatedUndoManager else { return } - undoManager.parent?.beginGrouping() - - let pasteboard = NSPasteboard.general - if pasteboard.canReadItem(withDataConformingToTypes: [UTType.text.identifier]), - let string = NSPasteboard.general.string(forType: .string) { - for textRange in textLayoutManager - .textSelections - .flatMap(\.textRanges) - .sorted(by: { $0.location.compare($1.location) == .orderedDescending }) { - if let nsRange = textRange.nsRange(using: textContentManager) { - undoManager.registerMutation( - TextMutation(insert: string, at: nsRange.location, limit: textContentStorage?.length ?? 0) - ) - } - replaceCharacters(in: textRange, with: string) - } - } - - undoManager.parent?.endGrouping() - } -} diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 891cc6749..79d9b2c98 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -6,10 +6,9 @@ // import SwiftUI -import STTextView +import CodeEditInputView import CodeEditLanguages -/// A `SwiftUI` wrapper for a ``STTextViewController``. public struct CodeEditTextView: NSViewControllerRepresentable { /// Initializes a Text Editor @@ -22,7 +21,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - indentOption: The behavior to use when the tab key is pressed. Defaults to 4 spaces. /// - lineHeight: The line height multiplier (e.g. `1.2`) /// - wrapLines: Whether lines wrap to the width of the editor - /// - editorOverscroll: The percentage for overscroll, between 0-1 (default: `0.0`) + /// - editorOverscroll: The distance to overscroll the editor by. /// - cursorPosition: The cursor's position in the editor, measured in `(lineNum, columnNum)` /// - useThemeBackground: Determines whether the editor uses the theme's background color, or a transparent /// background color @@ -31,6 +30,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. + /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this + /// value is true, and `isEditable` is false, the editor is selectable but not editable. /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. @@ -45,15 +46,17 @@ public struct CodeEditTextView: NSViewControllerRepresentable { indentOption: IndentOption = .spaces(count: 4), lineHeight: Double, wrapLines: Bool, - editorOverscroll: Double = 0.0, - cursorPosition: Binding<(Int, Int)>, + 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 + undoManager: CEUndoManager? = nil, + coordinators: [any TextViewCoordinator] = [] ) { self._text = text self.language = language @@ -65,13 +68,15 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.lineHeight = lineHeight self.wrapLines = wrapLines self.editorOverscroll = editorOverscroll - self._cursorPosition = cursorPosition + self._cursorPositions = cursorPositions self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable + self.isSelectable = isSelectable self.letterSpacing = letterSpacing self.bracketPairHighlight = bracketPairHighlight - self.undoManager = undoManager ?? CEUndoManager() + self.undoManager = undoManager + self.coordinators = coordinators } @Binding private var text: String @@ -82,21 +87,23 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var indentOption: IndentOption private var lineHeight: Double private var wrapLines: Bool - private var editorOverscroll: Double - @Binding private var cursorPosition: (Int, Int) + private var editorOverscroll: CGFloat + @Binding private var cursorPositions: [CursorPosition] private var useThemeBackground: Bool private var highlightProvider: HighlightProviding? private var contentInsets: NSEdgeInsets? private var isEditable: Bool + private var isSelectable: Bool private var letterSpacing: Double private var bracketPairHighlight: BracketPairHighlight? - private var undoManager: CEUndoManager + private var undoManager: CEUndoManager? + private var coordinators: [any TextViewCoordinator] - public typealias NSViewControllerType = STTextViewController + public typealias NSViewControllerType = TextViewController - public func makeNSViewController(context: Context) -> NSViewControllerType { - let controller = NSViewControllerType( - text: $text, + public func makeNSViewController(context: Context) -> TextViewController { + let controller = TextViewController( + string: text, language: language, font: font, theme: theme, @@ -104,20 +111,45 @@ public struct CodeEditTextView: NSViewControllerRepresentable { indentOption: indentOption, lineHeight: lineHeight, wrapLines: wrapLines, - cursorPosition: $cursorPosition, + cursorPositions: cursorPositions, editorOverscroll: editorOverscroll, useThemeBackground: useThemeBackground, highlightProvider: highlightProvider, contentInsets: contentInsets, isEditable: isEditable, + isSelectable: isSelectable, letterSpacing: letterSpacing, bracketPairHighlight: bracketPairHighlight, undoManager: undoManager ) + if controller.textView == nil { + controller.loadView() + } + if !cursorPositions.isEmpty { + controller.setCursorPositions(cursorPositions) + } + + context.coordinator.controller = controller + coordinators.forEach { + $0.prepareCoordinator(controller: controller) + } return controller } - public func updateNSViewController(_ controller: NSViewControllerType, context: Context) { + public func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + public func updateNSViewController(_ controller: TextViewController, context: Context) { + if !context.coordinator.isUpdateFromTextView { + // Prevent infinite loop of update notifications + context.coordinator.isUpdatingFromRepresentable = true + controller.setCursorPositions(cursorPositions) + context.coordinator.isUpdatingFromRepresentable = false + } else { + context.coordinator.isUpdateFromTextView = false + } + // Do manual diffing to reduce the amount of reloads. // This helps a lot in view performance, as it otherwise gets triggered on each environment change. guard !paramsAreEqual(controller: controller) else { @@ -130,9 +162,14 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.lineHeightMultiple = lineHeight controller.editorOverscroll = editorOverscroll controller.contentInsets = contentInsets - controller.bracketPairHighlight = bracketPairHighlight + if controller.isEditable != isEditable { + controller.isEditable = isEditable + } + + if controller.isSelectable != isSelectable { + controller.isSelectable = isSelectable + } - // Updating the language, theme, tab width and indent option needlessly can cause highlights to be re-calculated if controller.language.id != language.id { controller.language = language } @@ -149,12 +186,16 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.letterSpacing = letterSpacing } + controller.bracketPairHighlight = bracketPairHighlight + controller.reloadUI() return } func paramsAreEqual(controller: NSViewControllerType) -> Bool { controller.font == font && + controller.isEditable == isEditable && + controller.isSelectable == isSelectable && controller.wrapLines == wrapLines && controller.useThemeBackground == useThemeBackground && controller.lineHeightMultiple == lineHeight && @@ -167,4 +208,65 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.letterSpacing == letterSpacing && controller.bracketPairHighlight == bracketPairHighlight } + + @MainActor + public class Coordinator: NSObject { + var parent: CodeEditTextView + var controller: TextViewController? + var isUpdatingFromRepresentable: Bool = false + var isUpdateFromTextView: Bool = false + + init(parent: CodeEditTextView) { + self.parent = parent + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(textViewDidChangeText(_:)), + name: TextView.textDidChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textControllerCursorsDidUpdate(_:)), + name: TextViewController.cursorPositionUpdatedNotification, + object: nil + ) + } + + @objc func textViewDidChangeText(_ notification: Notification) { + guard let textView = notification.object as? TextView, + let controller, + controller.textView === textView else { + return + } + parent.text = textView.string + parent.coordinators.forEach { + $0.textViewDidChangeText(controller: controller) + } + } + + @objc func textControllerCursorsDidUpdate(_ notification: Notification) { + guard !isUpdatingFromRepresentable else { return } + self.isUpdateFromTextView = true + self.parent._cursorPositions.wrappedValue = self.controller?.cursorPositions ?? [] + if self.controller != nil { + self.parent.coordinators.forEach { + $0.textViewDidChangeSelection( + controller: self.controller!, + newPositions: self.controller!.cursorPositions + ) + } + } + } + + deinit { + parent.coordinators.forEach { + $0.destroy() + } + parent.coordinators.removeAll() + NotificationCenter.default.removeObserver(self) + } + } } diff --git a/Sources/CodeEditTextView/Controller/CursorPosition.swift b/Sources/CodeEditTextView/Controller/CursorPosition.swift new file mode 100644 index 000000000..a399626e2 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/CursorPosition.swift @@ -0,0 +1,67 @@ +// +// CursorPosition.swift +// CodeEditTextView +// +// Created by Khan Winter on 11/13/23. +// + +import Foundation + +/// # Cursor Position +/// +/// Represents the position of a cursor in a document. +/// Provides information about the range of the selection relative to the document, and the line-column information. +/// +/// Can be initialized by users without knowledge of either column and line position or range in the document. +/// When initialized by users, certain values may be set to `NSNotFound` or `-1` until they can be filled in by the text +/// controller. +/// +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 + /// in the``TextViewController/cursorPositions`` array. + /// + /// - Parameters: + /// - line: The line of the cursor position, 1-indexed. + /// - column: The column of the cursor position, 1-indexed. + public init(line: Int, column: Int) { + self.range = .notFound + self.line = line + self.column = column + } + + /// 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 + /// appears in the ``TextViewController/cursorPositions`` array. + /// + /// - Parameter range: The range of the cursor position. + public init(range: NSRange) { + self.range = range + self.line = -1 + self.column = -1 + } + + /// Private initializer. + /// - Parameters: + /// - range: The range of the position. + /// - line: The line of the position. + /// - column: The column of the position. + init(range: NSRange, line: Int, column: Int) { + self.range = range + self.line = line + self.column = column + } + + /// The range of the selection. + public let range: NSRange + /// The line the cursor is located at. 1-indexed. + /// If ``CursorPosition/range`` is not empty, this is the line at the beginning of the selection. + public let line: Int + /// The column the cursor is located at. 1-indexed. + /// If ``CursorPosition/range`` is not empty, this is the column at the beginning of the selection. + public let column: Int +} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift deleted file mode 100644 index eb50be6d8..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// STTextViewController+Cursor.swift -// -// -// Created by Elias Wahl on 15.03.23. -// - -import Foundation -import AppKit - -extension STTextViewController { - func setCursorPosition(_ position: (Int, Int)) { - guard let provider = textView.textLayoutManager.textContentManager else { - return - } - - var (line, column) = position - let string = textView.string - if line > 0 { - if string.isEmpty { - // If the file is blank, automatically place the cursor in the first index. - let range = NSRange(string.startIndex.. Bool - in - var col = 1 - /// If the cursor is at the end of the document: - if textLayoutManager.offset(from: insertionPointLocation, to: documentEndLocation) == 0 { - /// If document is empty: - if textLayoutManager.offset(from: documentStartLocation, to: documentEndLocation) == 0 { - self.cursorPosition.wrappedValue = (1, 1) - return false - } - guard let cursorTextFragment = textLayoutManager.textLayoutFragment(for: textSegmentFrame.origin), - let cursorTextLineFragment = cursorTextFragment.textLineFragments.last - else { return false } - - col = cursorTextLineFragment.characterRange.length + 1 - if col == 1 { line += 1 } - } else { - guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation) - else { return false } - - /// +1, because we start with the first character with 1 - let tempCol = cursorTextLineFragment.characterIndex(for: textSegmentFrame.origin) - let result = tempCol.addingReportingOverflow(1) - - if !result.overflow { col = result.partialValue } - /// If cursor is at end of line add 1: - if cursorTextLineFragment.characterRange.length != 1 && - (cursorTextLineFragment.typographicBounds.width == (textSegmentFrame.maxX + 5.0)) { - col += 1 - } - - /// If cursor is at first character of line, the current line is not being included - if col == 1 { line += 1 } - } - - DispatchQueue.main.async { - self.cursorPosition.wrappedValue = (line, col) - } - return false - } - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift deleted file mode 100644 index 54f42f054..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// STTextViewController+Lifecycle.swift -// CodeEditTextView -// -// Created by Khan Winter on 5/3/23. -// - -import AppKit -import STTextView - -extension STTextViewController { - public override func loadView() { - textView = CETextView() - - let scrollView = CEScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.hasVerticalScroller = true - scrollView.documentView = textView - scrollView.automaticallyAdjustsContentInsets = contentInsets == nil - - rulerView = STLineNumberRulerView(textView: textView, scrollView: scrollView) - rulerView.drawSeparator = false - rulerView.baselineOffset = baselineOffset - rulerView.allowsMarkers = false - rulerView.backgroundColor = theme.background - rulerView.textColor = .secondaryLabelColor - - scrollView.verticalRulerView = rulerView - scrollView.rulersVisible = true - - textView.typingAttributes = attributesFor(nil) - textView.typingAttributes[.paragraphStyle] = self.paragraphStyle - textView.font = self.font - textView.insertionPointWidth = 1.0 - textView.backgroundColor = .clear - - textView.string = self.text.wrappedValue - textView.allowsUndo = true - textView.setupMenus() - textView.delegate = self - - scrollView.documentView = textView - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.backgroundColor = useThemeBackground ? theme.background : .clear - - self.view = scrollView - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - self.keyDown(with: event) - return event - } - - textViewUndoManager.textView = textView - reloadUI() - setUpHighlighter() - setHighlightProvider(self.highlightProvider) - setUpTextFormation() - - self.setCursorPosition(self.cursorPosition.wrappedValue) - } - - public override func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, - object: nil, - queue: .main) { [weak self] _ in - guard let self = self else { return } - (self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets - self.updateTextContainerWidthIfNeeded() - } - - NotificationCenter.default.addObserver( - forName: STTextView.didChangeSelectionNotification, - object: nil, - queue: .main - ) { [weak self] _ in - let textSelections = self?.textView.textLayoutManager.textSelections.flatMap(\.textRanges) - guard self?.lastTextSelections != textSelections else { - return - } - self?.lastTextSelections = textSelections ?? [] - - self?.updateCursorPosition() - self?.highlightSelectionPairs() - } - - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: (self.view as? NSScrollView)?.verticalRulerView, - queue: .main - ) { [weak self] _ in - self?.updateTextContainerWidthIfNeeded() - if self?.bracketPairHighlight == .flash { - self?.removeHighlightLayers() - } - } - - systemAppearance = NSApp.effectiveAppearance.name - - NSApp.publisher(for: \.effectiveAppearance) - .receive(on: RunLoop.main) - .sink { [weak self] newValue in - guard let self = self else { return } - - if self.systemAppearance != newValue.name { - self.systemAppearance = newValue.name - } - } - .store(in: &cancellables) - } - - public override func viewWillAppear() { - super.viewWillAppear() - updateTextContainerWidthIfNeeded(true) - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift b/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift deleted file mode 100644 index 390353aa9..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// STTextViewController+STTextViewDelegate.swift -// CodeEditTextView -// -// Created by Khan Winter on 7/8/23. -// - -import AppKit -import STTextView -import TextStory - -extension STTextViewController { - public func undoManager(for textView: STTextView) -> UndoManager? { - textViewUndoManager.manager - } - - public func textView( - _ textView: STTextView, - shouldChangeTextIn affectedCharRange: NSTextRange, - replacementString: String? - ) -> Bool { - guard let textContentStorage = textView.textContentStorage, - let range = affectedCharRange.nsRange(using: textContentStorage), - !textViewUndoManager.isUndoing, - !textViewUndoManager.isRedoing else { - return true - } - - let mutation = TextMutation( - string: replacementString ?? "", - range: range, - limit: textView.textContentStorage?.length ?? 0 - ) - - let result = shouldApplyMutation(mutation, to: textView) - - if result { - textViewUndoManager.registerMutation(mutation) - } - - return result - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift b/Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift deleted file mode 100644 index 2b007921a..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// STTextViewController+TextContainer.swift -// -// -// Created by Khan Winter on 4/21/23. -// - -import AppKit -import STTextView - -extension STTextViewController { - /// Update the text view's text container if needed. - /// - /// Effectively updates the container to reflect the `wrapLines` setting, and to reflect any updates to the ruler, - /// scroll view, or window frames. - internal func updateTextContainerWidthIfNeeded(_ forceUpdate: Bool = false) { - let previousTrackingSetting = textView.widthTracksTextView - textView.widthTracksTextView = wrapLines - guard let scrollView = view as? NSScrollView else { - return - } - - if wrapLines { - var proposedSize = scrollView.contentSize - proposedSize.height = .greatestFiniteMagnitude - - if textView.textContainer.size != proposedSize || textView.frame.size != proposedSize || forceUpdate { - textView.textContainer.size = proposedSize - textView.setFrameSize(proposedSize) - } - } else { - var proposedSize = textView.frame.size - proposedSize.width = scrollView.contentSize.width - if previousTrackingSetting != wrapLines || forceUpdate { - textView.textContainer.size = CGSize( - width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude - ) - textView.setFrameSize(proposedSize) - textView.textLayoutManager.textViewportLayoutController.layoutViewport() - } - } - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift deleted file mode 100644 index 92185c52f..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ /dev/null @@ -1,268 +0,0 @@ -// -// STTextViewController.swift -// CodeEditTextView -// -// Created by Lukas Pistrol on 24.05.22. -// - -import AppKit -import SwiftUI -import Combine -import STTextView -import CodeEditLanguages -import TextFormation -import TextStory - -/// A View Controller managing and displaying a `STTextView` -public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAttributesProviding { - - internal var textView: STTextView! - - internal var rulerView: STLineNumberRulerView! - - /// Internal reference to any injected layers in the text view. - internal var highlightLayers: [CALayer] = [] - - /// Tracks the last text selections. Used to debounce `STTextView.didChangeSelectionNotification` being sent twice - /// for every new selection. - internal var lastTextSelections: [NSTextRange] = [] - - internal var textViewUndoManager: CEUndoManager - - /// Binding for the `textView`s string - public var text: Binding - - /// The associated `CodeLanguage` - public var language: CodeLanguage { didSet { - // TODO: Decide how to handle errors thrown here - highlighter?.setLanguage(language: language) - }} - - /// The associated `Theme` used for highlighting. - public var theme: EditorTheme { didSet { - highlighter?.invalidate() - }} - - /// Whether the code editor should use the theme background color or be transparent - public var useThemeBackground: Bool - - public var systemAppearance: NSAppearance.Name? - - var cancellables = Set() - - /// The visual width of tab characters in the text view measured in number of spaces. - public var tabWidth: Int { - didSet { - paragraphStyle = generateParagraphStyle() - reloadUI() - } - } - - /// The behavior to use when the tab key is pressed. - public var indentOption: IndentOption { - didSet { - setUpTextFormation() - } - } - - /// A multiplier for setting the line height. Defaults to `1.0` - public var lineHeightMultiple: Double = 1.0 - - /// The font to use in the `textView` - public var font: NSFont - - /// The current cursor position e.g. (1, 1) - public var cursorPosition: Binding<(Int, Int)> - - /// The editorOverscroll to use for the textView over scroll - public var editorOverscroll: Double - - /// Whether lines wrap to the width of the editor - public var wrapLines: Bool - - /// Whether or not text view is editable by user - public var isEditable: Bool - - /// Filters used when applying edits.. - internal var textFilters: [TextFormation.Filter] = [] - - /// Optional insets to offset the text view in the scroll view by. - public var contentInsets: NSEdgeInsets? - - /// 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 = 1.0 { - didSet { - kern = fontCharWidth * (letterSpacing - 1.0) - reloadUI() - } - } - - /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. - public var bracketPairHighlight: BracketPairHighlight? - - /// The kern to use for characters. Defaults to `0.0` and is updated when `letterSpacing` is set. - internal var kern: CGFloat = 0.0 - - private var fontCharWidth: CGFloat { - (" " as NSString).size(withAttributes: [.font: font]).width - } - - // MARK: - Highlighting - - internal var highlighter: Highlighter? - - /// The provided highlight provider. - internal var highlightProvider: HighlightProviding? - - // MARK: Init - - public init( - text: Binding, - language: CodeLanguage, - font: NSFont, - theme: EditorTheme, - tabWidth: Int, - indentOption: IndentOption, - lineHeight: Double, - wrapLines: Bool, - cursorPosition: Binding<(Int, Int)>, - editorOverscroll: Double, - useThemeBackground: Bool, - highlightProvider: HighlightProviding? = nil, - contentInsets: NSEdgeInsets? = nil, - isEditable: Bool, - letterSpacing: Double, - bracketPairHighlight: BracketPairHighlight? = nil, - undoManager: CEUndoManager - ) { - self.text = text - self.language = language - self.font = font - self.theme = theme - self.tabWidth = tabWidth - self.indentOption = indentOption - self.lineHeightMultiple = lineHeight - self.wrapLines = wrapLines - self.cursorPosition = cursorPosition - self.editorOverscroll = editorOverscroll - self.useThemeBackground = useThemeBackground - self.highlightProvider = highlightProvider - self.contentInsets = contentInsets - self.isEditable = isEditable - self.bracketPairHighlight = bracketPairHighlight - self.textViewUndoManager = undoManager - super.init(nibName: nil, bundle: nil) - } - - required init(coder: NSCoder) { - fatalError() - } - - public func textViewDidChangeText(_ notification: Notification) { - self.text.wrappedValue = textView.string - } - - // MARK: UI - - /// A default `NSParagraphStyle` with a set `lineHeight` - internal lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() - - private func generateParagraphStyle() -> NSMutableParagraphStyle { - // swiftlint:disable:next force_cast - let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle - paragraph.minimumLineHeight = lineHeight - paragraph.maximumLineHeight = lineHeight - paragraph.tabStops.removeAll() - paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth - return paragraph - } - - /// ScrollView's bottom inset using as editor overscroll - internal var bottomContentInsets: CGFloat { - let height = view.frame.height - var inset = editorOverscroll * height - - if height - inset < lineHeight { - inset = height - lineHeight - } - - return max(inset, .zero) - } - - /// Reloads the UI to apply changes to ``STTextViewController/font``, ``STTextViewController/theme``, ... - internal func reloadUI() { - textView.textColor = theme.text - textView.insertionPointColor = theme.insertionPoint - textView.selectionBackgroundColor = theme.selection - textView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua - ? NSColor.quaternaryLabelColor - : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - textView.isEditable = isEditable - textView.highlightSelectedLine = isEditable - textView.typingAttributes = attributesFor(nil) - paragraphStyle = generateParagraphStyle() - textView.typingAttributes = attributesFor(nil) - - rulerView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua - ? NSColor.quaternaryLabelColor - : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - rulerView.baselineOffset = baselineOffset - rulerView.highlightSelectedLine = isEditable - rulerView.rulerInsets = STRulerInsets(leading: 12, trailing: 8) - rulerView.font = rulerFont - rulerView.backgroundColor = theme.background - rulerView.ruleThickness = max( - NSString(string: "1000").size(withAttributes: [.font: rulerFont]).width - + rulerView.rulerInsets.leading - + rulerView.rulerInsets.trailing, - rulerView.ruleThickness - ) - if self.isEditable == false { - rulerView.selectedLineTextColor = nil - rulerView.selectedLineHighlightColor = .clear - } - - if let scrollView = view as? NSScrollView { - scrollView.drawsBackground = useThemeBackground - scrollView.backgroundColor = useThemeBackground ? theme.background : .clear - if let contentInsets = contentInsets { - scrollView.contentInsets = contentInsets - } - scrollView.contentInsets.bottom = bottomContentInsets + (contentInsets?.bottom ?? 0) - } - - highlighter?.invalidate() - updateTextContainerWidthIfNeeded() - highlightSelectionPairs() - } - - /// Calculated line height depending on ``STTextViewController/lineHeightMultiple`` - internal var lineHeight: Double { - font.lineHeight * lineHeightMultiple - } - - /// Calculated baseline offset depending on `lineHeight`. - internal var baselineOffset: Double { - ((self.lineHeight) - font.lineHeight) / 2 + 2 - } - - // MARK: Selectors - - override public func keyDown(with event: NSEvent) { - if bracketPairHighlight == .flash { - removeHighlightLayers() - } - } - - public override func insertTab(_ sender: Any?) { - textView.insertText("\t", replacementRange: textView.selectedRange) - } - - deinit { - removeHighlightLayers() - textView = nil - highlighter = nil - cancellables.forEach { $0.cancel() } - } -} diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift new file mode 100644 index 000000000..b1e977535 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift @@ -0,0 +1,56 @@ +// +// TextViewController+Cursor.swift +// CodeEditTextView +// +// Created by Elias Wahl on 15.03.23. +// + +import Foundation +import AppKit + +extension TextViewController { + /// Sets new cursor positions. + /// - Parameter positions: The positions to set. Lines and columns are 1-indexed. + public func setCursorPositions(_ positions: [CursorPosition]) { + _ = textView.becomeFirstResponder() + + var newSelectedRanges: [NSRange] = [] + for position in positions { + let line = position.line + let column = position.column + guard (line > 0 && column > 0) || (position.range != .notFound) else { continue } + + if position.range == .notFound { + if textView.textStorage.length == 0 { + // If the file is blank, automatically place the cursor in the first index. + 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) + ) + newSelectedRanges.append(NSRange(location: index, length: 0)) + } + } else { + newSelectedRanges.append(position.range) + } + } + textView.selectionManager.setSelectedRanges(newSelectedRanges) + } + + /// Update the ``TextViewController/cursorPositions`` variable with new text selections from the text view. + func updateCursorPosition() { + var positions: [CursorPosition] = [] + for selectedRange in textView.selectionManager.textSelections { + guard let linePosition = textView.layoutManager.textLineForOffset(selectedRange.range.location) else { + continue + } + let column = (selectedRange.range.location - linePosition.range.location) + 1 + let row = linePosition.index + 1 + positions.append(CursorPosition(range: selectedRange.range, line: row, column: column)) + } + cursorPositions = positions.sorted(by: { $0.range.location < $1.range.location }) + NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: nil) + } +} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift similarity index 81% rename from Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift rename to Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift index fb7a26c32..16c8e4958 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift @@ -1,24 +1,22 @@ // -// STTextViewController+HighlightRange.swift +// TextViewController+HighlightRange.swift // CodeEditTextView // // Created by Khan Winter on 4/26/23. // import AppKit -import STTextView -extension STTextViewController { +extension TextViewController { /// Highlights bracket pairs using the current selection. internal func highlightSelectionPairs() { guard bracketPairHighlight != nil else { return } removeHighlightLayers() - for selection in textView.textLayoutManager.textSelections.flatMap(\.textRanges) { - if selection.isEmpty, - let range = selection.nsRange(using: textView.textContentManager), + for range in textView.selectionManager.textSelections.map({ $0.range }) { + if range.isEmpty, range.location > 0, // Range is not the beginning of the document - let preceedingCharacter = textView.textContentStorage?.textStorage?.substring( - from: NSRange(location: range.location - 1, length: 1) // The preceeding character exists + let preceedingCharacter = textView.textStorage.substring( + from: NSRange(location: range.location - 1, length: 1) // The preceding character exists ) { for pair in BracketPairs.allValues { if preceedingCharacter == pair.0 { @@ -31,9 +29,9 @@ extension STTextViewController { NSMaxRange(textView.documentRange)), reverse: false ) { - highlightRange(NSRange(location: characterIndex, length: 1)) + highlightCharacter(characterIndex) if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightRange(NSRange(location: range.location - 1, length: 1)) + highlightCharacter(range.location - 1) } } } else if preceedingCharacter == pair.1 && range.location - 1 > 0 { @@ -46,9 +44,9 @@ extension STTextViewController { textView.documentRange.location), reverse: true ) { - highlightRange(NSRange(location: characterIndex, length: 1)) + highlightCharacter(characterIndex) if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightRange(NSRange(location: range.location - 1, length: 1)) + highlightCharacter(range.location - 1) } } } @@ -57,6 +55,11 @@ extension STTextViewController { } } + /// # Dev Note + /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each + /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed + /// and this lookup would be much faster. + /// Finds a closing character given a pair of characters, ignores pairs inside the given pair. /// /// ```pseudocode @@ -65,6 +68,7 @@ extension STTextViewController { /// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. /// } -- Found /// ``` + /// /// - Parameters: /// - open: The opening pair to look for. /// - close: The closing pair to look for. @@ -82,7 +86,7 @@ extension STTextViewController { } var closeCount = 0 var index: Int? - textView.textContentStorage?.textStorage?.mutableString.enumerateSubstrings( + textView.textStorage.mutableString.enumerateSubstrings( in: reverse ? NSRange(location: limit, length: from - limit) : NSRange(location: from, length: limit - from), @@ -103,15 +107,13 @@ extension STTextViewController { return index } - /// Adds a temporary highlight effect to the given range. + /// Adds a temporary highlight effect to the character at the given location. /// - Parameters: - /// - range: The range to highlight + /// - location: The location of the character to highlight /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. - private func highlightRange(_ range: NSTextRange, scrollToRange: Bool = false) { + private func highlightCharacter(_ location: Int, scrollToRange: Bool = false) { guard let bracketPairHighlight = bracketPairHighlight, - var rectToHighlight = textView.textLayoutManager.textSegmentFrame( - in: range, type: .highlight - ) else { + var rectToHighlight = textView.layoutManager.rectForOffset(location) else { return } let layer = CAShapeLayer() @@ -145,7 +147,7 @@ extension STTextViewController { layer.frame = rectToHighlight case .underline: let path = CGMutablePath() - let pathY = rectToHighlight.maxY - (lineHeight - font.lineHeight)/4 + let pathY = rectToHighlight.maxY - (rectToHighlight.height * (lineHeightMultiple - 1))/4 path.move(to: CGPoint(x: rectToHighlight.minX, y: pathY)) path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: pathY)) layer.path = path @@ -209,18 +211,6 @@ extension STTextViewController { CATransaction.commit() } - /// Adds a temporary highlight effect to the given range. - /// - Parameters: - /// - range: The range to highlight - /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. - public func highlightRange(_ range: NSRange, scrollToRange: Bool = false) { - guard let textRange = NSTextRange(range, provider: textView.textContentManager) else { - return - } - - highlightRange(textRange, scrollToRange: scrollToRange) - } - /// Safely removes all highlight layers. internal func removeHighlightLayers() { highlightLayers.forEach { layer in diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift similarity index 59% rename from Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift rename to Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift index e1d113597..1f1c1201f 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift @@ -1,16 +1,20 @@ // -// STTextViewController+Highlighter.swift +// TextViewController+Highlighter.swift +// CodeEditTextView // -// -// Created by Khan Winter on 4/21/23. +// Created by Khan Winter on 10/14/23. // -import AppKit +import Foundation import SwiftTreeSitter -extension STTextViewController { - /// Configures the `Highlighter` object +extension TextViewController { internal func setUpHighlighter() { + if let highlighter { + textView.removeStorageDelegate(highlighter) + self.highlighter = nil + } + self.highlighter = Highlighter( textView: textView, highlightProvider: highlightProvider, @@ -18,9 +22,10 @@ extension STTextViewController { attributeProvider: self, language: language ) + textView.addStorageDelegate(highlighter!) + setHighlightProvider(self.highlightProvider) } - /// Sets the highlight provider and re-highlights all text. This method should be used sparingly. internal func setHighlightProvider(_ highlightProvider: HighlightProviding? = nil) { var provider: HighlightProviding? @@ -28,7 +33,7 @@ extension STTextViewController { provider = highlightProvider } else { let textProvider: ResolvingQueryCursor.TextProvider = { [weak self] range, _ -> String? in - return self?.textView.textContentStorage?.textStorage?.mutableString.substring(with: range) + return self?.textView.textStorage.mutableString.substring(with: range) } provider = TreeSitterClient(textProvider: textProvider) @@ -39,17 +44,14 @@ extension STTextViewController { highlighter?.setHighlightProvider(provider) } } +} - /// Gets all attributes for the given capture including the line height, background color, and text color. - /// - Parameter capture: The capture to use for syntax highlighting. - /// - Returns: All attributes to be applied. +extension TextViewController: ThemeAttributesProviding { public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { - return [ + [ .font: font, .foregroundColor: theme.colorFor(capture), - .baselineOffset: baselineOffset, - .paragraphStyle: paragraphStyle, - .kern: kern + .kern: textView.kern ] } } diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift new file mode 100644 index 000000000..668f05a4b --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift @@ -0,0 +1,117 @@ +// +// TextViewController+LoadView.swift +// CodeEditTextView +// +// Created by Khan Winter on 10/14/23. +// + +import CodeEditInputView +import AppKit + +extension TextViewController { + // swiftlint:disable:next function_body_length + override public func loadView() { + scrollView = NSScrollView() + textView.postsFrameChangedNotifications = true + textView.translatesAutoresizingMaskIntoConstraints = false + textView.selectionManager.insertionPointColor = theme.insertionPoint + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.contentView.postsFrameChangedNotifications = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.documentView = textView + scrollView.contentView.postsBoundsChangedNotifications = true + scrollView.backgroundColor = useThemeBackground ? theme.background : .clear + if let contentInsets { + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = contentInsets + } + + gutterView = GutterView( + font: font.rulerFont, + textColor: .secondaryLabelColor, + textView: textView, + delegate: self + ) + gutterView.frame.origin.y = -scrollView.contentInsets.top + gutterView.backgroundColor = useThemeBackground ? theme.background : .textBackgroundColor + gutterView.updateWidthIfNeeded() + scrollView.addFloatingSubview( + gutterView, + for: .horizontal + ) + + self.view = scrollView + if let _undoManager { + textView.setUndoManager(_undoManager) + } + setUpHighlighter() + setUpTextFormation() + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + if !cursorPositions.isEmpty { + setCursorPositions(cursorPositions) + } + + // Layout on scroll change + NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + } + + // Layout on frame change + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + if self?.bracketPairHighlight == .flash { + self?.removeHighlightLayers() + } + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: textView, + queue: .main + ) { [weak self] _ in + self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 + self?.gutterView.needsDisplay = true + } + + NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: textView.selectionManager, + queue: .main + ) { [weak self] _ in + self?.updateCursorPosition() + self?.highlightSelectionPairs() + } + + textView.updateFrameIfNeeded() + + NSApp.publisher(for: \.effectiveAppearance) + .receive(on: RunLoop.main) + .sink { [weak self] newValue in + guard let self = self else { return } + + if self.systemAppearance != newValue.name { + self.systemAppearance = newValue.name + } + } + .store(in: &cancellables) + } +} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift similarity index 96% rename from Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift rename to Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift index c17c79918..07f376662 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift @@ -1,16 +1,16 @@ // -// STTextViewController+TextFormation.swift -// +// TextViewController+TextFormation.swift +// CodeEditTextView // // Created by Khan Winter on 1/26/23. // import AppKit -import STTextView +import CodeEditInputView import TextFormation import TextStory -extension STTextViewController { +extension TextViewController { internal enum BracketPairs { static let allValues: [(String, String)] = [ @@ -88,7 +88,7 @@ extension STTextViewController { /// - mutation: The text mutation. /// - textView: The textView to use. /// - Returns: Return whether or not the mutation should be applied. - internal func shouldApplyMutation(_ mutation: TextMutation, to textView: STTextView) -> Bool { + internal func shouldApplyMutation(_ mutation: TextMutation, to textView: TextView) -> Bool { // don't perform any kind of filtering during undo operations if textView.undoManager?.isUndoing ?? false || textView.undoManager?.isRedoing ?? false { return true diff --git a/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift new file mode 100644 index 000000000..4707af07b --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift @@ -0,0 +1,26 @@ +// +// TextViewController+TextViewDelegate.swift +// CodeEditTextView +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation +import CodeEditInputView +import TextStory + +extension TextViewController: TextViewDelegate { + public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { + gutterView.needsDisplay = true + } + + public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { + let mutation = TextMutation( + string: string, + range: range, + limit: textView.textStorage.length + ) + + return shouldApplyMutation(mutation, to: textView) + } +} diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift new file mode 100644 index 000000000..6c3bfe25e --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -0,0 +1,308 @@ +// +// TextViewController.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/25/23. +// + +import AppKit +import CodeEditInputView +import CodeEditLanguages +import SwiftUI +import Combine +import TextFormation + +/// # TextViewController +/// +/// A view controller class for managing a source editor. Uses ``CodeEditInputView/TextView`` for input and rendering, +/// tree-sitter for syntax highlighting, and TextFormation for live editing completions. +/// +public class TextViewController: NSViewController { + // swiftlint:disable:next line_length + public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + + var scrollView: NSScrollView! + var textView: TextView! + var gutterView: GutterView! + internal var _undoManager: CEUndoManager? + /// Internal reference to any injected layers in the text view. + internal var highlightLayers: [CALayer] = [] + internal var systemAppearance: NSAppearance.Name? + + /// The string contents. + public var string: String { + textView.string + } + + /// The associated `CodeLanguage` + public var language: CodeLanguage { + didSet { + highlighter?.setLanguage(language: language) + } + } + + /// The font to use in the `textView` + public var font: NSFont { + didSet { + textView.font = font + highlighter?.invalidate() + } + } + + /// The associated `Theme` used for highlighting. + public var theme: EditorTheme { + didSet { + textView.layoutManager.setNeedsLayout() + textView.textStorage.setAttributes( + attributesFor(nil), + range: NSRange(location: 0, length: textView.textStorage.length) + ) + highlighter?.invalidate() + } + } + + /// The visual width of tab characters in the text view measured in number of spaces. + public var tabWidth: Int { + didSet { + paragraphStyle = generateParagraphStyle() + textView.layoutManager.setNeedsLayout() + highlighter?.invalidate() + } + } + + /// The behavior to use when the tab key is pressed. + public var indentOption: IndentOption { + didSet { + setUpTextFormation() + } + } + + /// A multiplier for setting the line height. Defaults to `1.0` + public var lineHeightMultiple: CGFloat { + didSet { + textView.layoutManager.lineHeightMultiplier = lineHeightMultiple + } + } + + /// Whether lines wrap to the width of the editor + public var wrapLines: Bool { + didSet { + textView.layoutManager.wrapLines = wrapLines + } + } + + /// The current cursors' positions ordered by the location of the cursor. + internal(set) public var cursorPositions: [CursorPosition] = [] + + /// The editorOverscroll to use for the textView over scroll + /// + /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll + /// of 1/3 of the view. + public var editorOverscroll: CGFloat + + /// Whether the code editor should use the theme background color or be transparent + public var useThemeBackground: Bool + + /// The provided highlight provider. + public var highlightProvider: HighlightProviding? + + /// Optional insets to offset the text view in the scroll view by. + public var contentInsets: NSEdgeInsets? + + /// Whether or not text view is editable by user + public var isEditable: Bool { + didSet { + textView.isEditable = isEditable + } + } + + /// Whether or not text view is selectable by user + public var isSelectable: Bool { + didSet { + textView.isSelectable = isSelectable + } + } + + /// 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 = 1.0 { + didSet { + textView.letterSpacing = letterSpacing + highlighter?.invalidate() + } + } + + /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. + public var bracketPairHighlight: BracketPairHighlight? { + didSet { + highlightSelectionPairs() + } + } + + /// Passthrough value for the `textView`s string + public var text: String { + get { + textView.string + } + set { + self.setText(newValue) + } + } + + internal var highlighter: Highlighter? + + private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } + + /// Filters used when applying edits.. + internal var textFilters: [TextFormation.Filter] = [] + + internal var cancellables = Set() + + /// ScrollView's bottom inset using as editor overscroll + private var bottomContentInsets: CGFloat { + let height = view.frame.height + var inset = editorOverscroll * height + + if height - inset < font.lineHeight * lineHeightMultiple { + inset = height - font.lineHeight * lineHeightMultiple + } + + return max(inset, .zero) + } + + // MARK: Init + + init( + string: String, + language: CodeLanguage, + font: NSFont, + theme: EditorTheme, + tabWidth: Int, + indentOption: IndentOption, + lineHeight: CGFloat, + wrapLines: Bool, + cursorPositions: [CursorPosition], + editorOverscroll: CGFloat, + useThemeBackground: Bool, + highlightProvider: HighlightProviding?, + contentInsets: NSEdgeInsets?, + isEditable: Bool, + isSelectable: Bool, + letterSpacing: Double, + bracketPairHighlight: BracketPairHighlight?, + undoManager: CEUndoManager? = nil + ) { + self.language = language + self.font = font + self.theme = theme + self.tabWidth = tabWidth + self.indentOption = indentOption + self.lineHeightMultiple = lineHeight + self.wrapLines = wrapLines + self.cursorPositions = cursorPositions + self.editorOverscroll = editorOverscroll + self.useThemeBackground = useThemeBackground + self.highlightProvider = highlightProvider + self.contentInsets = contentInsets + self.isEditable = isEditable + self.isSelectable = isSelectable + self.letterSpacing = letterSpacing + self.bracketPairHighlight = bracketPairHighlight + self._undoManager = undoManager + + super.init(nibName: nil, bundle: nil) + + self.textView = TextView( + string: string, + font: font, + textColor: theme.text, + lineHeightMultiplier: lineHeightMultiple, + wrapLines: wrapLines, + isEditable: isEditable, + isSelectable: isSelectable, + letterSpacing: letterSpacing, + delegate: self + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Set the contents of the editor. + /// - Parameter text: The new contents of the editor. + public func setText(_ text: String) { + self.textView.setText(text) + self.setUpHighlighter() + self.gutterView.setNeedsDisplay(self.gutterView.frame) + } + + // MARK: Paragraph Style + + /// A default `NSParagraphStyle` with a set `lineHeight` + internal lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + + private func generateParagraphStyle() -> NSMutableParagraphStyle { + // swiftlint:disable:next force_cast + let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle + paragraph.tabStops.removeAll() + paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth + return paragraph + } + + // MARK: - Reload UI + + func reloadUI() { + textView.isEditable = isEditable + textView.isSelectable = isSelectable + + textView.selectionManager.selectionBackgroundColor = theme.selection + textView.selectionManager.selectedLineBackgroundColor = useThemeBackground + ? theme.lineHighlight + : systemAppearance == .darkAqua + ? NSColor.quaternaryLabelColor : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + textView.selectionManager.highlightSelectedLine = isEditable + textView.selectionManager.insertionPointColor = theme.insertionPoint + paragraphStyle = generateParagraphStyle() + textView.typingAttributes = attributesFor(nil) + + gutterView.selectedLineColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua + ? NSColor.quaternaryLabelColor + : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + gutterView.highlightSelectedLines = isEditable + gutterView.font = font.rulerFont + gutterView.backgroundColor = useThemeBackground ? theme.background : .textBackgroundColor + if self.isEditable == false { + gutterView.selectedLineTextColor = nil + gutterView.selectedLineColor = .clear + } + + if let scrollView = view as? NSScrollView { + scrollView.drawsBackground = useThemeBackground + scrollView.backgroundColor = useThemeBackground ? theme.background : .clear + if let contentInsets = contentInsets { + scrollView.contentInsets = contentInsets + } + scrollView.contentInsets.bottom = (contentInsets?.bottom ?? 0) + bottomContentInsets + } + + highlighter?.invalidate() + } + + deinit { + if let highlighter { + textView.removeStorageDelegate(highlighter) + } + highlighter = nil + highlightProvider = nil + NotificationCenter.default.removeObserver(self) + cancellables.forEach { $0.cancel() } + } +} + +extension TextViewController: GutterViewDelegate { + public func gutterViewWidthDidUpdate(newWidth: CGFloat) { + gutterView?.frame.size.width = newWidth + textView?.edgeInsets = HorizontalEdgeInsets(left: newWidth, right: 0) + } +} diff --git a/Sources/CodeEditTextView/Enums/CaptureName.swift b/Sources/CodeEditTextView/Enums/CaptureName.swift index 9c8599cad..0112782a8 100644 --- a/Sources/CodeEditTextView/Enums/CaptureName.swift +++ b/Sources/CodeEditTextView/Enums/CaptureName.swift @@ -1,5 +1,5 @@ // -// STTextViewController+CaptureNames.swift +// CaptureNames.swift // CodeEditTextView // // Created by Lukas Pistrol on 16.08.22. diff --git a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift b/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift index 2dd9e5730..18f9347c4 100644 --- a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift +++ b/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift @@ -6,6 +6,7 @@ // import AppKit +import CodeEditInputView public extension NSFont { /// The default line height of the font. diff --git a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift b/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift index b733dded0..feae2e564 100644 --- a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift +++ b/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift @@ -8,11 +8,11 @@ import Foundation import AppKit -extension STTextViewController { +extension NSFont { var rulerFont: NSFont { - let fontSize: Double = (font.pointSize - 1) + 0.25 - let fontAdvance: Double = font.pointSize * 0.49 + 0.1 - let fontWeight = NSFont.Weight(rawValue: font.pointSize * 0.00001 + 0.0001) + let fontSize: Double = (self.pointSize - 1) + 0.25 + let fontAdvance: Double = self.pointSize * 0.49 + 0.1 + let fontWeight = NSFont.Weight(rawValue: self.pointSize * 0.00001 + 0.0001) let fontWidth = NSFont.Width(rawValue: -0.13) let font = NSFont.systemFont(ofSize: fontSize, weight: fontWeight, width: fontWidth) diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift new file mode 100644 index 000000000..d06be1df2 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift @@ -0,0 +1,14 @@ +// +// NSRange+isEmpty.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation + +extension NSRange { + var isEmpty: Bool { + length == 0 + } +} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+AutoComplete.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+AutoComplete.swift deleted file mode 100644 index 12046d590..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+AutoComplete.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// STTextView+AutoComplete.swift -// CodeEditTextView -// -// Created by Lukas Pistrol on 25.05.22. -// - -import AppKit -import STTextView - -extension STTextView { - - /// Corresponding closing brackets for given opening bracket. - /// - /// The following pairs are currently implemented: - /// * `(` : `)` - /// * `{` : `}` - /// * `[` : `]` - private var bracketPairs: [String: String] { - [ - "(": ")", - "{": "}", - "[": "]" - // not working yet - // "\"": "\"", - // "\'": "\'" - ] - } - - /// Add closing bracket and move curser back one symbol if applicable. - /// - Parameter symbol: The symbol to check for - func autocompleteBracketPairs(_ symbol: String) { - guard let end = bracketPairs[symbol], - nextSymbol() != end else { return } - insertText(end, replacementRange: selectedRange()) - moveBackward(self) - } - - /// Returns the symbol right of the cursor. - private func nextSymbol() -> String { - let start = selectedRange().location - let nextRange = NSRange(location: start, length: 1) - guard let nextSymbol = string[nextRange] else { - return "" - } - return String(nextSymbol) - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+ContentStorage.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+ContentStorage.swift deleted file mode 100644 index e7f1f29c2..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+ContentStorage.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// STTextView+ContentStorage.swift -// -// -// Created by Khan Winter on 4/24/23. -// - -import Foundation -import AppKit -import STTextView - -extension STTextView { - /// Convenience that unwraps `textContentManager` as an `NSTextContentStorage` subclass. - var textContentStorage: NSTextContentStorage? { - return textContentManager as? NSTextContentStorage - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+HighlighterTextView.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+HighlighterTextView.swift deleted file mode 100644 index a9e7b0cf6..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+HighlighterTextView.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// STTextView+HighlighterTextView.swift -// CodeEditTextView -// -// Created by Khan Winter on 6/2/23. -// - -import Foundation -import STTextView - -/// A default implementation for `STTextView` to be passed to `HighlightProviding` objects. -extension STTextView: HighlighterTextView { - public var documentRange: NSRange { - return NSRange( - location: 0, - length: textContentStorage?.textStorage?.length ?? 0 - ) - } - - public func stringForRange(_ nsRange: NSRange) -> String? { - return textContentStorage?.textStorage?.mutableString.substring(with: nsRange) - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift deleted file mode 100644 index 575203dcc..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// STTextView+TextInterface.swift -// -// -// Created by Khan Winter on 1/26/23. -// - -import AppKit -import STTextView -import TextStory -import TextFormation - -extension STTextView: TextInterface { - public var selectedRange: NSRange { - get { - return self.selectedRange() - } - set { - guard let textContentStorage = textContentStorage else { - return - } - if let textRange = NSTextRange(newValue, provider: textContentStorage) { - self.setSelectedTextRange(textRange) - } - } - } - - public var length: Int { - textContentStorage?.length ?? 0 - } - - public func substring(from range: NSRange) -> String? { - return textContentStorage?.substring(from: range) - } - - /// Applies the mutation to the text view. - /// - Parameter mutation: The mutation to apply. - public func applyMutation(_ mutation: TextMutation) { - registerUndo(mutation) - applyMutationNoUndo(mutation) - } - - fileprivate func registerUndo(_ mutation: TextMutation) { - if let manager = undoManager as? CEUndoManager.DelegatedUndoManager { - manager.registerMutation(mutation) - } - } - - public func applyMutationNoUndo(_ mutation: TextMutation) { - textContentStorage?.performEditingTransaction { - textContentStorage?.applyMutation(mutation) - } - - let delegate = self.delegate - self.delegate = nil - textDidChange(nil) - self.delegate = delegate - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift deleted file mode 100644 index a79376979..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// STTextView+VisibleRange.swift -// -// -// Created by Khan Winter on 9/12/22. -// - -import Foundation -import STTextView -import AppKit - -extension STTextView { - /// A helper for calculating the visible range on the text view with some small padding. - var visibleTextRange: NSRange? { - guard let textContentStorage = textContentStorage, - var range = textLayoutManager - .textViewportLayoutController - .viewportRange? - .nsRange(using: textContentStorage) else { - return nil - } - range.location = max(range.location - 2500, 0) - range.length = min(range.length + 2500, textContentStorage.length) - return range - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+Menu.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift similarity index 98% rename from Sources/CodeEditTextView/Extensions/STTextView+/STTextView+Menu.swift rename to Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift index 105bc949f..2baa4b200 100644 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+Menu.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift @@ -1,14 +1,14 @@ // -// STTextView+Menu.swift +// TextView+Menu.swift // CodeEditTextView // // Created by Lukas Pistrol on 25.05.22. // import AppKit -import STTextView +import CodeEditInputView -extension STTextView { +extension TextView { /// Setup context menus func setupMenus() { diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift new file mode 100644 index 000000000..a1e5cb800 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift @@ -0,0 +1,45 @@ +// +// TextView+TextFormation.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation +import CodeEditInputView +import TextStory +import TextFormation + +extension TextView: TextInterface { + public var selectedRange: NSRange { + get { + return selectionManager + .textSelections + .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) + .first? + .range ?? .zero + } + set { + selectionManager.setSelectedRange(newValue) + } + } + + public var length: Int { + textStorage.length + } + + public func substring(from range: NSRange) -> String? { + return textStorage.substring(from: range) + } + + /// Applies the mutation to the text view. + /// - Parameter mutation: The mutation to apply. + public func applyMutation(_ mutation: TextMutation) { + layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) + selectionManager.didReplaceCharacters( + in: mutation.range, + replacementLength: (mutation.string as NSString).length + ) + textStorage.replaceCharacters(in: mutation.range, with: mutation.string) + } +} diff --git a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift index 18478c97e..bfac27860 100644 --- a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift +++ b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift @@ -6,9 +6,9 @@ // import Foundation +import CodeEditInputView import TextFormation import TextStory -import STTextView /// Filter for quickly deleting indent whitespace /// @@ -33,7 +33,8 @@ struct DeleteWhitespaceFilter: Filter { in interface: TextInterface, with providers: WhitespaceProviders ) -> FilterAction { - guard mutation.string == "" + guard mutation.delta < 0 + && mutation.string == "" && mutation.range.length == 1 && indentOption != .tab else { return .none @@ -59,10 +60,6 @@ struct DeleteWhitespaceFilter: Filter { ) ) - if let textView = interface as? STTextView, textView.textLayoutManager.textSelections.count == 1 { - textView.setSelectedRange(NSRange(location: leadingWhitespace.max - numberOfExtraSpaces, length: 0)) - } - return .discard } } diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift new file mode 100644 index 000000000..6d02aade4 --- /dev/null +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -0,0 +1,210 @@ +// +// GutterView.swift +// +// +// Created by Khan Winter on 8/22/23. +// + +import AppKit +import CodeEditInputView + +public protocol GutterViewDelegate: AnyObject { + func gutterViewWidthDidUpdate(newWidth: CGFloat) +} + +public class GutterView: NSView { + struct EdgeInsets: Equatable, Hashable { + let leading: CGFloat + let trailing: CGFloat + + var horizontal: CGFloat { + leading + trailing + } + } + + @Invalidating(.display) + var textColor: NSColor = .secondaryLabelColor + + @Invalidating(.display) + var font: NSFont = .systemFont(ofSize: 13) + + @Invalidating(.display) + var edgeInsets: EdgeInsets = EdgeInsets(leading: 20, trailing: 12) + + @Invalidating(.display) + var backgroundColor: NSColor? = NSColor.controlBackgroundColor + + @Invalidating(.display) + var highlightSelectedLines: Bool = true + + @Invalidating(.display) + var selectedLineTextColor: NSColor? = .textColor + + @Invalidating(.display) + var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + + private(set) public var gutterWidth: CGFloat = 0 + + private weak var textView: TextView? + private weak var delegate: GutterViewDelegate? + private var maxWidth: CGFloat = 0 + /// The maximum number of digits found for a line number. + private var maxLineLength: Int = 0 + + override public var isFlipped: Bool { + true + } + + override public var wantsDefaultClipping: Bool { + false + } + + public init( + font: NSFont, + textColor: NSColor, + textView: TextView, + delegate: GutterViewDelegate? = nil + ) { + self.font = font + self.textColor = textColor + self.textView = textView + self.delegate = delegate + + super.init(frame: .zero) + clipsToBounds = false + wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + translatesAutoresizingMaskIntoConstraints = false + layer?.masksToBounds = false + + NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: nil, + queue: .main + ) { _ in + self.needsDisplay = true + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Updates the width of the gutter if needed. + func updateWidthIfNeeded() { + guard let textView else { return } + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor + ] + let originalMaxWidth = maxWidth + // Reserve at least 3 digits of space no matter what + let lineStorageDigits = max(3, String(textView.layoutManager.lineCount).count) + + if maxLineLength < lineStorageDigits { + // Update the max width + let maxCtLine = CTLineCreateWithAttributedString( + NSAttributedString(string: String(repeating: "0", count: lineStorageDigits), attributes: attributes) + ) + let width = CTLineGetTypographicBounds(maxCtLine, nil, nil, nil) + maxWidth = max(maxWidth, width) + maxLineLength = lineStorageDigits + } + + if originalMaxWidth != maxWidth { + gutterWidth = maxWidth + edgeInsets.horizontal + delegate?.gutterViewWidthDidUpdate(newWidth: maxWidth + edgeInsets.horizontal) + } + } + + private func drawSelectedLines(_ context: CGContext) { + guard let textView = textView, + let selectionManager = textView.selectionManager, + let visibleRange = textView.visibleTextRange, + highlightSelectedLines else { + return + } + context.saveGState() + var highlightedLines: Set = [] + context.setFillColor(selectedLineColor.cgColor) + for selection in selectionManager.textSelections + where selection.range.isEmpty { + guard let line = textView.layoutManager.textLineForOffset(selection.range.location), + visibleRange.intersection(line.range) != nil || selection.range.location == textView.length, + !highlightedLines.contains(line.data.id) else { + continue + } + highlightedLines.insert(line.data.id) + context.fill( + CGRect( + x: 0.0, + y: line.yPos, + width: maxWidth + edgeInsets.horizontal, + height: line.height + ) + ) + } + context.restoreGState() + } + + private func drawLineNumbers(_ context: CGContext) { + guard let textView = textView else { return } + var attributes: [NSAttributedString.Key: Any] = [.font: font] + + var selectionRangeMap = IndexSet() + textView.selectionManager?.textSelections.forEach { + if $0.range.isEmpty { + selectionRangeMap.insert($0.range.location) + } else { + selectionRangeMap.insert(range: $0.range) + } + } + + context.saveGState() + context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) + for linePosition in textView.layoutManager.visibleLines() { + if selectionRangeMap.intersects(integersIn: linePosition.range) { + attributes[.foregroundColor] = selectedLineTextColor ?? textColor + } else { + attributes[.foregroundColor] = textColor + } + + let ctLine = CTLineCreateWithAttributedString( + NSAttributedString(string: "\(linePosition.index + 1)", attributes: attributes) + ) + let fragment: LineFragment? = linePosition.data.lineFragments.first?.data + var ascent: CGFloat = 0 + let lineNumberWidth = CTLineGetTypographicBounds(ctLine, &ascent, nil, nil) + + let yPos = linePosition.yPos + ascent + (fragment?.heightDifference ?? 0)/2 + // Leading padding + (width - linewidth) + let xPos = edgeInsets.leading + (maxWidth - lineNumberWidth) + + context.textPosition = CGPoint(x: xPos, y: yPos).pixelAligned + CTLineDraw(ctLine, context) + } + context.restoreGState() + } + + override public func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { + return + } + CATransaction.begin() + superview?.clipsToBounds = false + superview?.layer?.masksToBounds = false + layer?.backgroundColor = backgroundColor?.cgColor + updateWidthIfNeeded() + drawSelectedLines(context) + drawLineNumbers(context) + CATransaction.commit() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift index 88ed36ea8..9ac761987 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift +++ b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift @@ -7,7 +7,6 @@ import Foundation import CodeEditLanguages -import STTextView import AppKit /// The protocol a class must conform to to be used for highlighting. diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift index b0d96932b..a2485d7e3 100644 --- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift +++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift @@ -7,11 +7,11 @@ import Foundation import AppKit -import STTextView +import CodeEditInputView import SwiftTreeSitter import CodeEditLanguages -/// The `Highlighter` class handles efficiently highlighting the `STTextView` it's provided with. +/// The `Highlighter` class handles efficiently highlighting the `TextView` it's provided with. /// It will listen for text and visibility changes, and highlight syntax as needed. /// /// One should rarely have to direcly modify or call methods on this class. Just keep it alive in @@ -32,7 +32,7 @@ class Highlighter: NSObject { /// The range of the entire document private var entireTextRange: Range { - return 0..<(textView.textContentStorage?.textStorage?.length ?? 0) + return 0..<(textView.textStorage.length) } /// The set of visible indexes in tht text view @@ -43,19 +43,19 @@ class Highlighter: NSObject { // MARK: - UI /// The text view to highlight - private var textView: STTextView + private unowned var textView: TextView /// The editor theme private var theme: EditorTheme /// The object providing attributes for captures. - private var attributeProvider: ThemeAttributesProviding! + private weak var attributeProvider: ThemeAttributesProviding! /// The current language of the editor. private var language: CodeLanguage /// Calculates invalidated ranges given an edit. - private var highlightProvider: HighlightProviding? + private weak var highlightProvider: HighlightProviding? /// The length to chunk ranges into when passing to the highlighter. fileprivate let rangeChunkLimit = 256 @@ -68,7 +68,7 @@ class Highlighter: NSObject { /// - treeSitterClient: The tree-sitter client to handle tree updates and highlight queries. /// - theme: The theme to use for highlights. init( - textView: STTextView, + textView: TextView, highlightProvider: HighlightProviding?, theme: EditorTheme, attributeProvider: ThemeAttributesProviding, @@ -82,12 +82,6 @@ class Highlighter: NSObject { super.init() - guard textView.textContentStorage?.textStorage != nil else { - assertionFailure("Text view does not have a textStorage") - return - } - - textView.textContentStorage?.textStorage?.delegate = self highlightProvider?.setUp(textView: textView, codeLanguage: language) if let scrollView = textView.enclosingScrollView { @@ -167,8 +161,10 @@ private extension Highlighter { func highlight(range rangeToHighlight: NSRange) { pendingSet.insert(integersIn: rangeToHighlight) - highlightProvider?.queryHighlightsFor(textView: self.textView, - range: rangeToHighlight) { [weak self] highlightRanges in + highlightProvider?.queryHighlightsFor( + textView: self.textView, + range: rangeToHighlight + ) { [weak self] highlightRanges in guard let attributeProvider = self?.attributeProvider, let textView = self?.textView else { return } @@ -178,26 +174,16 @@ private extension Highlighter { } self?.validSet.formUnion(IndexSet(integersIn: rangeToHighlight)) - // Try to create a text range for invalidating. If this fails we fail silently - guard let textContentManager = textView.textLayoutManager.textContentManager, - let textRange = NSTextRange(rangeToHighlight, provider: textContentManager) else { - return - } - // Loop through each highlight and modify the textStorage accordingly. - textView.textContentStorage?.textStorage?.beginEditing() + textView.layoutManager.beginTransaction() + textView.textStorage.beginEditing() // Create a set of indexes that were not highlighted. var ignoredIndexes = IndexSet(integersIn: rangeToHighlight) // Apply all highlights that need color for highlight in highlightRanges { - // Does not work: -// textView.textLayoutManager.setRenderingAttributes(attributeProvider.attributesFor(highlight.capture), -// for: NSTextRange(highlight.range, -// provider: textView.textContentStorage)!) - // Temp solution (until Apple fixes above) - textView.textContentStorage?.textStorage?.setAttributes( + textView.textStorage.setAttributes( attributeProvider.attributesFor(highlight.capture), range: highlight.range ) @@ -210,17 +196,14 @@ private extension Highlighter { // This fixes the case where characters are changed to have a non-text color, and then are skipped when // they need to be changed back. for ignoredRange in ignoredIndexes.rangeView { - textView.textContentStorage?.textStorage?.setAttributes( + textView.textStorage.setAttributes( attributeProvider.attributesFor(nil), range: NSRange(ignoredRange) ) } - textView.textContentStorage?.textStorage?.endEditing() - - // After applying edits to the text storage we need to invalidate the layout - // of the highlighted text. - textView.textLayoutManager.invalidateLayout(for: textRange) + textView.textStorage.endEditing() + textView.layoutManager.endTransaction() } } diff --git a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift b/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift index ff6b2ac7f..44ce505d3 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift +++ b/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift @@ -7,7 +7,7 @@ import Foundation import AppKit -import STTextView +import CodeEditInputView /// The object `HighlightProviding` objects are given when asked for highlights. public protocol HighlighterTextView: AnyObject { @@ -16,3 +16,9 @@ public protocol HighlighterTextView: AnyObject { /// A substring for the requested range. func stringForRange(_ nsRange: NSRange) -> String? } + +extension TextView: HighlighterTextView { + public func stringForRange(_ nsRange: NSRange) -> String? { + textStorage.substring(from: nsRange) + } +} diff --git a/Sources/CodeEditTextView/TextViewCoordinator.swift b/Sources/CodeEditTextView/TextViewCoordinator.swift new file mode 100644 index 000000000..c59685d2e --- /dev/null +++ b/Sources/CodeEditTextView/TextViewCoordinator.swift @@ -0,0 +1,41 @@ +// +// TextViewCoordinator.swift +// CodeEditTextView +// +// 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. +/// +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. + func prepareCoordinator(controller: TextViewController) + + /// Called when the text view's text changed. + /// - Parameter controller: The text controller. + func textViewDidChangeText(controller: TextViewController) + + /// Called after the text view updated it's cursor positions. + /// - Parameter newPositions: The new positions of the cursors. + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) + + /// Called when the text controller is being destroyed. Use to free any necessary resources. + func destroy() +} + +/// Default implementations +public extension TextViewCoordinator { + func textViewDidChangeText(controller: TextViewController) { } + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { } + func destroy() { } +} diff --git a/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift b/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift index e7f335f9f..d1a32f884 100644 --- a/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift +++ b/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift @@ -8,6 +8,6 @@ import Foundation /// Classes conforming to this protocol can provide attributes for text given a capture type. -public protocol ThemeAttributesProviding { +public protocol ThemeAttributesProviding: AnyObject { func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] } diff --git a/Tests/CodeEditInputViewTests/LineEndingTests.swift b/Tests/CodeEditInputViewTests/LineEndingTests.swift new file mode 100644 index 000000000..211f5c843 --- /dev/null +++ b/Tests/CodeEditInputViewTests/LineEndingTests.swift @@ -0,0 +1,92 @@ +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 new file mode 100644 index 000000000..3c37db746 --- /dev/null +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -0,0 +1,278 @@ +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 new file mode 100644 index 000000000..bd4f623fa --- /dev/null +++ b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift @@ -0,0 +1,220 @@ +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/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift similarity index 59% rename from Tests/CodeEditTextViewTests/STTextViewControllerTests.swift rename to Tests/CodeEditTextViewTests/TextViewControllerTests.swift index 09ab0f4d5..7ae5c49fe 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift @@ -2,12 +2,13 @@ import XCTest @testable import CodeEditTextView import SwiftTreeSitter import AppKit +import SwiftUI import TextStory // swiftlint:disable all -final class STTextViewControllerTests: XCTestCase { +final class TextViewControllerTests: XCTestCase { - var controller: STTextViewController! + var controller: TextViewController! var theme: EditorTheme! override func setUpWithError() throws { @@ -29,8 +30,8 @@ final class STTextViewControllerTests: XCTestCase { characters: .systemRed, comments: .systemGreen ) - controller = STTextViewController( - text: .constant(""), + controller = TextViewController( + string: "", language: .default, font: .monospacedSystemFont(ofSize: 11, weight: .medium), theme: theme, @@ -38,17 +39,22 @@ final class STTextViewControllerTests: XCTestCase { indentOption: .spaces(count: 4), lineHeight: 1.0, wrapLines: true, - cursorPosition: .constant((1, 1)), + cursorPositions: [], editorOverscroll: 0.5, useThemeBackground: true, + highlightProvider: nil, + contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), isEditable: true, + isSelectable: true, letterSpacing: 1.0, - undoManager: CEUndoManager() + bracketPairHighlight: .flash ) controller.loadView() } + // MARK: Capture Names + func test_captureNames() throws { // test for "keyword" let captureName1 = "keyword" @@ -73,6 +79,8 @@ final class STTextViewControllerTests: XCTestCase { XCTAssertEqual(color4, NSColor.textColor) } + // MARK: Overscroll + func test_editorOverScroll() throws { let scrollView = try XCTUnwrap(controller.view as? NSScrollView) scrollView.frame = .init(x: .zero, @@ -100,6 +108,8 @@ final class STTextViewControllerTests: XCTestCase { XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) } + // MARK: Insets + func test_editorInsets() throws { let scrollView = try XCTUnwrap(controller.view as? NSScrollView) scrollView.frame = .init(x: .zero, @@ -150,6 +160,8 @@ final class STTextViewControllerTests: XCTestCase { XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) } + // MARK: Indent + func test_indentOptionString() { XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) @@ -163,37 +175,39 @@ final class STTextViewControllerTests: XCTestCase { func test_indentBehavior() { // Insert 1 space controller.indentOption = .spaces(count: 1) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") - controller.insertTab(nil) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) + controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 2 spaces controller.indentOption = .spaces(count: 2) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") + controller.textView.textStorage.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 3 spaces controller.indentOption = .spaces(count: 3) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 4 spaces controller.indentOption = .spaces(count: 4) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert tab controller.indentOption = .tab - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, "\t") // Insert lots of spaces controller.indentOption = .spaces(count: 1000) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") + print(controller.textView.textStorage.length) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) } @@ -217,33 +231,37 @@ final class STTextViewControllerTests: XCTestCase { controller.letterSpacing = 1.0 } + // MARK: Bracket Highlights + func test_bracketHighlights() { + controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() controller.bracketPairHighlight = nil controller.textView.string = "{ Loren Ipsum {} }" - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) controller.bracketPairHighlight = .bordered(color: .black) - controller.setCursorPosition((1, 2)) // After first opening { + controller.textView.setNeedsDisplay() + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") controller.bracketPairHighlight = .underline(color: .black) - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") controller.bracketPairHighlight = .flash - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") let exp = expectation(description: "Test after 0.8 seconds") let result = XCTWaiter.wait(for: [exp], timeout: 0.8) @@ -286,5 +304,106 @@ final class STTextViewControllerTests: XCTestCase { idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") } + + // MARK: Set Text + + func test_setText() { + controller.textView.string = "Hello World" + controller.textView.selectionManager.setSelectedRange(NSRange(location: 1, length: 2)) + + controller.setText("\nHello World with newline!") + + XCTAssert(controller.string == "\nHello World with newline!") + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 1) + XCTAssertEqual(controller.cursorPositions[0].range.length, 2) + XCTAssertEqual(controller.textView.selectionManager.textSelections.count, 1) + XCTAssertEqual(controller.textView.selectionManager.textSelections[0].range.location, 1) + XCTAssertEqual(controller.textView.selectionManager.textSelections[0].range.length, 2) + } + + // MARK: Cursor Positions + + func test_cursorPositionRangeInit() { + controller.setText("Hello World") + + // Test adding a position returns a valid one + controller.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 5))]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 0) + XCTAssertEqual(controller.cursorPositions[0].range.length, 5) + XCTAssertEqual(controller.cursorPositions[0].line, 1) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test an invalid position is ignored + controller.setCursorPositions([CursorPosition(range: NSRange(location: -1, length: 25))]) + XCTAssertTrue(controller.cursorPositions.count == 0) + + // Test that column and line are correct + controller.setText("1\n2\n3\n4\n") + controller.setCursorPositions([CursorPosition(range: NSRange(location: 2, length: 0))]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 2) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test order and validity of multiple positions. + controller.setCursorPositions([ + CursorPosition(range: NSRange(location: 2, length: 0)), + CursorPosition(range: NSRange(location: 5, length: 1)) + ]) + XCTAssertEqual(controller.cursorPositions.count, 2) + XCTAssertEqual(controller.cursorPositions[0].range.location, 2) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[1].range.location, 5) + XCTAssertEqual(controller.cursorPositions[1].range.length, 1) + XCTAssertEqual(controller.cursorPositions[1].line, 3) + XCTAssertEqual(controller.cursorPositions[1].column, 2) + } + + func test_cursorPositionRowColInit() { + controller.setText("Hello World") + + // Test adding a position returns a valid one + controller.setCursorPositions([CursorPosition(line: 1, column: 1)]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 0) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 1) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test an invalid position is ignored + controller.setCursorPositions([CursorPosition(line: -1, column: 10)]) + XCTAssertTrue(controller.cursorPositions.count == 0) + + // Test that column and line are correct + controller.setText("1\n2\n3\n4\n") + controller.setCursorPositions([CursorPosition(line: 2, column: 1)]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 2) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test order and validity of multiple positions. + controller.setCursorPositions([ + CursorPosition(line: 1, column: 1), + CursorPosition(line: 3, column: 1) + ]) + XCTAssertEqual(controller.cursorPositions.count, 2) + XCTAssertEqual(controller.cursorPositions[0].range.location, 0) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 1) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[1].range.location, 4) + XCTAssertEqual(controller.cursorPositions[1].range.length, 0) + XCTAssertEqual(controller.cursorPositions[1].line, 3) + XCTAssertEqual(controller.cursorPositions[1].column, 1) + } } // swiftlint:enable all