diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift index 43a1bdcb9..790a38fbb 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift @@ -25,7 +25,7 @@ struct CodeEditTextViewExampleDocument: FileDocument { guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } - text = String(decoding: data, as: UTF8.self) + text = String(bytes: data, encoding: .utf8) } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift index 2de61d2d6..0ed8a5716 100644 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift @@ -11,8 +11,8 @@ import AppKit public class EmphasizeAPI { // MARK: - Properties - private var highlightedRanges: [EmphasizedRange] = [] - private var emphasizedRangeIndex: Int? + public private(set) var emphasizedRanges: [EmphasizedRange] = [] + public private(set) var emphasizedRangeIndex: Int? private let activeColor: NSColor = NSColor(hex: 0xFFFB00, alpha: 1) private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) @@ -23,8 +23,8 @@ public class EmphasizeAPI { } // MARK: - Structs - private struct EmphasizedRange { - var range: NSRange + public struct EmphasizedRange { + public var range: NSRange var layer: CAShapeLayer } @@ -61,18 +61,18 @@ public class EmphasizeAPI { let layer = createEmphasizeLayer(shapePath: shapePath, active: active) textView?.layer?.insertSublayer(layer, at: 1) - highlightedRanges.append(EmphasizedRange(range: range, layer: layer)) + emphasizedRanges.append(EmphasizedRange(range: range, layer: layer)) } /// Removes the highlight for a specific range. /// - Parameter range: The range to remove. public func removeHighlightForRange(_ range: NSRange) { - guard let index = highlightedRanges.firstIndex(where: { $0.range == range }) else { return } + guard let index = emphasizedRanges.firstIndex(where: { $0.range == range }) else { return } - let removedLayer = highlightedRanges[index].layer + let removedLayer = emphasizedRanges[index].layer removedLayer.removeFromSuperlayer() - highlightedRanges.remove(at: index) + emphasizedRanges.remove(at: index) // Adjust the active highlight index if let currentIndex = emphasizedRangeIndex { @@ -105,8 +105,8 @@ public class EmphasizeAPI { /// Removes all emphasised ranges. public func removeEmphasizeLayers() { - highlightedRanges.forEach { $0.layer.removeFromSuperlayer() } - highlightedRanges.removeAll() + emphasizedRanges.forEach { $0.layer.removeFromSuperlayer() } + emphasizedRanges.removeAll() emphasizedRangeIndex = nil } @@ -147,29 +147,29 @@ public class EmphasizeAPI { /// - Returns: An optional `NSRange` representing the newly active highlight, colored in the active color. /// Returns `nil` if no change occurred (e.g., if there are no highlighted ranges). private func shiftActiveHighlight(amount: Int) -> NSRange? { - guard !highlightedRanges.isEmpty else { return nil } + guard !emphasizedRanges.isEmpty else { return nil } var currentIndex = emphasizedRangeIndex ?? -1 - currentIndex = (currentIndex + amount + highlightedRanges.count) % highlightedRanges.count + currentIndex = (currentIndex + amount + emphasizedRanges.count) % emphasizedRanges.count - guard currentIndex < highlightedRanges.count else { return nil } + guard currentIndex < emphasizedRanges.count else { return nil } // Reset the previously active layer if let currentIndex = emphasizedRangeIndex { - let previousLayer = highlightedRanges[currentIndex].layer + let previousLayer = emphasizedRanges[currentIndex].layer previousLayer.fillColor = inactiveColor.cgColor previousLayer.shadowOpacity = 0.0 } // Set the new active layer - let newLayer = highlightedRanges[currentIndex].layer + let newLayer = emphasizedRanges[currentIndex].layer newLayer.fillColor = activeColor.cgColor newLayer.shadowOpacity = 0.3 applyPopAnimation(to: newLayer) emphasizedRangeIndex = currentIndex - return highlightedRanges[currentIndex].range + return emphasizedRanges[currentIndex].range } private func applyPopAnimation(to layer: CALayer) { diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index bf1d9ba42..00475ef9f 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -6,6 +6,7 @@ // import Foundation +import AppKit extension TextView { fileprivate typealias Direction = TextSelectionManager.Direction @@ -36,6 +37,69 @@ extension TextView { } } + /// Scrolls the view to the specified range. + /// + /// - Parameters: + /// - range: The range to scroll to. + /// - center: A flag that determines if the range should be centered in the view. Defaults to `true`. + /// + /// If `center` is `true`, the range will be centered in the visible area. + /// If `center` is `false`, the range will be aligned at the top-left of the view. + public func scrollToRange(_ range: NSRange, center: Bool = true) { + guard let scrollView else { return } + + guard let boundingRect = layoutManager.rectForOffset(range.location) else { return } + + // Check if the range is already visible + if visibleRect.contains(boundingRect) { + return // No scrolling needed + } + + // Calculate the target offset based on the center flag + let targetOffset: CGPoint + if center { + targetOffset = CGPoint( + x: max(boundingRect.midX - visibleRect.width / 2, 0), + y: max(boundingRect.midY - visibleRect.height / 2, 0) + ) + } else { + targetOffset = CGPoint( + x: max(boundingRect.origin.x, 0), + y: max(boundingRect.origin.y, 0) + ) + } + + var lastFrame: CGRect = .zero + + // Set a timeout to avoid an infinite loop + let timeout: TimeInterval = 0.5 + let startTime = Date() + + // Adjust layout until stable + while let newRect = layoutManager.rectForOffset(range.location), + lastFrame != newRect, + Date().timeIntervalSince(startTime) < timeout { + lastFrame = newRect + layoutManager.layoutLines() + selectionManager.updateSelectionViews() + selectionManager.drawSelections(in: visibleRect) + } + + // Scroll to make the range appear at the desired position + if lastFrame != .zero { + let animated = false // feature flag + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 // Adjust duration as needed + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + scrollView.contentView.animator().setBoundsOrigin(targetOffset) + } + } else { + scrollView.contentView.scroll(to: targetOffset) + } + } + } + /// Get the selection that should be scrolled to visible for the current text selection. /// - Returns: The the selection to scroll to. private func getSelection() -> TextSelection? {