From ea168f47fb5c539423c489f4c75945e48d51a9d6 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 28 Nov 2024 11:57:05 +0100 Subject: [PATCH 01/11] Add `cgPathFallback` property to `NSBezierPath` for macOS versions < 14 to support conversion to `CGPath` --- .../NSBezierPath+CGPathFallback.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift diff --git a/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift new file mode 100644 index 000000000..dcb1a7121 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift @@ -0,0 +1,34 @@ +// +// NSBezierPath+CGPathFallback.swift +// CodeEditTextView +// +// Created by Tom Ludwig on 27.11.24. +// + +import AppKit + +extension NSBezierPath { + /// Converts the `NSBezierPath` instance into a `CGPath`, providing a fallback method for compatibility(macOS < 14). + public var cgPathFallback: CGPath { + let path = CGMutablePath() + var points = [CGPoint](repeating: .zero, count: 3) + + for index in 0 ..< elementCount { + let type = element(at: index, associatedPoints: &points) + switch type { + case .moveTo: + path.move(to: points[0]) + case .lineTo: + path.addLine(to: points[0]) + case .curveTo: + path.addCurve(to: points[2], control1: points[0], control2: points[1]) + case .closePath: + path.closeSubpath() + @unknown default: + continue + } + } + + return path + } +} From ecdf562deb426013c06c378df7d8149405d69ad0 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 28 Nov 2024 11:57:30 +0100 Subject: [PATCH 02/11] Add convenience initializer to `NSColor` for creating color from hex value --- .../Extensions/NSColor+Hex.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Sources/CodeEditTextView/Extensions/NSColor+Hex.swift diff --git a/Sources/CodeEditTextView/Extensions/NSColor+Hex.swift b/Sources/CodeEditTextView/Extensions/NSColor+Hex.swift new file mode 100644 index 000000000..d52f6a0d4 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSColor+Hex.swift @@ -0,0 +1,17 @@ +// +// NSColor+Hex.swift +// CodeEditTextView +// +// Created by Tom Ludwig on 27.11.24. +// + +import AppKit + +extension NSColor { + convenience init(hex: Int, alpha: Double = 1.0) { + let red = (hex >> 16) & 0xFF + let green = (hex >> 8) & 0xFF + let blue = hex & 0xFF + self.init(srgbRed: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, alpha: alpha) + } +} From dfeb6ebec5db69211edcc8241709717a4affb9a2 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 28 Nov 2024 11:58:29 +0100 Subject: [PATCH 03/11] Add `smoothPath ` method to `NSBezierPath` for smooth path creation --- .../Extensions/NSBezierPath+SmoothPath.swift | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift diff --git a/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift b/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift new file mode 100644 index 000000000..114652f61 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift @@ -0,0 +1,121 @@ +// +// NSBezierPath+SmoothPath.swift +// CodeEditSourceEditor +// +// Created by Tom Ludwig on 12.11.24. +// + +import AppKit +import SwiftUI + +extension NSBezierPath { + private func quadCurve(to endPoint: CGPoint, controlPoint: CGPoint) { + guard pointIsValid(endPoint) && pointIsValid(controlPoint) else { return } + + let startPoint = self.currentPoint + let controlPoint1 = CGPoint(x: (startPoint.x + (controlPoint.x - startPoint.x) * 2.0 / 3.0), + y: (startPoint.y + (controlPoint.y - startPoint.y) * 2.0 / 3.0)) + let controlPoint2 = CGPoint(x: (endPoint.x + (controlPoint.x - endPoint.x) * 2.0 / 3.0), + y: (endPoint.y + (controlPoint.y - endPoint.y) * 2.0 / 3.0)) + + curve(to: endPoint, controlPoint1: controlPoint1, controlPoint2: controlPoint2) + } + + private func pointIsValid(_ point: CGPoint) -> Bool { + return !point.x.isNaN && !point.y.isNaN + } + + // swiftlint:disable:next function_body_length + static func smoothPath(_ points: [NSPoint], radius cornerRadius: CGFloat) -> NSBezierPath { + // Normalizing radius to compensate for the quadraticCurve + let radius = cornerRadius * 1.15 + + let path = NSBezierPath() + + guard points.count > 1 else { return path } + + // Calculate the initial corner start based on the first two points + let initialVector = NSPoint(x: points[1].x - points[0].x, y: points[1].y - points[0].y) + let initialDistance = sqrt(initialVector.x * initialVector.x + initialVector.y * initialVector.y) + + let initialUnitVector = NSPoint(x: initialVector.x / initialDistance, y: initialVector.y / initialDistance) + let initialCornerStart = NSPoint( + x: points[0].x + initialUnitVector.x * radius, + y: points[0].y + initialUnitVector.y * radius + ) + + // Start path at the initial corner start + path.move(to: points.first == points.last ? initialCornerStart : points[0]) + + for index in 1.. 0 ? NSPoint(x: vector1.x / distance1, y: vector1.y / distance1) : NSPoint.zero + let unitVector2 = distance2 > 0 ? NSPoint(x: vector2.x / distance2, y: vector2.y / distance2) : NSPoint.zero + + // This uses the dot product formula: cos(θ) = (u1 • u2), + // where u1 and u2 are unit vectors. The result will range from -1 to 1: + let angleCosine = unitVector1.x * unitVector2.x + unitVector1.y * unitVector2.y + + // If the cosine of the angle is less than 0.5 (i.e., angle > ~60 degrees), + // the radius is reduced to half to avoid overlapping or excessive smoothing. + let clampedRadius = angleCosine < 0.5 ? radius /** 0.5 */: radius // Adjust for sharp angles + + // Calculate the corner start and end + let cornerStart = NSPoint(x: p1.x - unitVector1.x * radius, y: p1.y - unitVector1.y * radius) + let cornerEnd = NSPoint(x: p1.x + unitVector2.x * radius, y: p1.y + unitVector2.y * radius) + + // Check if this segment is a straight line or a curve + if unitVector1 != unitVector2 { // There's a change in direction, add a curve + path.line(to: cornerStart) + path.quadCurve(to: cornerEnd, controlPoint: p1) + } else { // Straight line, just add a line + path.line(to: p1) + } + } + + // Handle the final segment if the path is closed + if points.first == points.last, points.count > 2 { + // Closing path by rounding back to the initial point + let lastPoint = points[points.count - 2] + let firstPoint = points[0] + + // Calculate the vectors and unit vectors + let finalVector = NSPoint(x: firstPoint.x - lastPoint.x, y: firstPoint.y - lastPoint.y) + let distance = sqrt(finalVector.x * finalVector.x + finalVector.y * finalVector.y) + let unitVector = NSPoint(x: finalVector.x / distance, y: finalVector.y / distance) + + // Calculate the final corner start and initial corner end + let finalCornerStart = NSPoint( + x: firstPoint.x - unitVector.x * radius, + y: firstPoint.y - unitVector.y * radius + ) + + let initialCornerEnd = NSPoint( + x: points[0].x + initialUnitVector.x * radius, + y: points[0].y + initialUnitVector.y * radius + ) + + path.line(to: finalCornerStart) + path.quadCurve(to: initialCornerEnd, controlPoint: firstPoint) + path.close() + + } else if let lastPoint = points.last { // For open paths, just connect to the last point + path.line(to: lastPoint) + } + + return path + } +} From 0f2d3a18096a1a5f59b90728d4758a315426c1f0 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 28 Nov 2024 11:59:10 +0100 Subject: [PATCH 04/11] Add `roundedPathForRange` method to create a smooth bezier path for a text range --- .../TextLayoutManager+Public.swift | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 938950ac8..34617613d 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -153,6 +153,94 @@ extension TextLayoutManager { ) } + // swiftlint:disable function_body_length + /// Creates a smooth bezier path for the specified range. + /// If the range exceeds the available text, it uses the maximum available range. + /// - Parameter range: The range of text offsets to generate the path for. + /// - Returns: An `NSBezierPath` representing the visual shape for the text range, or `nil` if the range is invalid. + public func roundedPathForRange(_ range: NSRange) -> NSBezierPath? { + // Ensure the range is within the bounds of the text storage + let validRange = NSRange( + location: range.lowerBound, + length: min(range.length, lineStorage.length - range.lowerBound) + ) + + guard validRange.length > 0 else { return rectForEndOffset().map { NSBezierPath(rect: $0) } } + + var rightSidePoints: [CGPoint] = [] // Points for Bottom-right → Top-right + var leftSidePoints: [CGPoint] = [] // Points for Bottom-left → Top-left + + var currentOffset = validRange.lowerBound + + // Process each line fragment within the range + while currentOffset < validRange.upperBound { + guard let linePosition = lineStorage.getLine(atOffset: currentOffset) else { return nil } + + if linePosition.data.lineFragments.isEmpty { + let newHeight = ensureLayoutFor(position: linePosition) + if linePosition.height != newHeight { + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) + } + } + + guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( + atOffset: currentOffset - linePosition.range.location + ) else { break } + + // Calculate the X positions for the range's boundaries within the fragment + let realRangeStart = (textStorage?.string as? NSString)? + .rangeOfComposedCharacterSequence(at: validRange.lowerBound) + ?? NSRange(location: validRange.lowerBound, length: 0) + + let realRangeEnd = (textStorage?.string as? NSString)? + .rangeOfComposedCharacterSequence(at: validRange.upperBound - 1) + ?? NSRange(location: validRange.upperBound - 1, length: 0) + + let minXPos = CTLineGetOffsetForStringIndex( + fragmentPosition.data.ctLine, + realRangeStart.location - linePosition.range.location, + nil + ) + edgeInsets.left + + let maxXPos = CTLineGetOffsetForStringIndex( + fragmentPosition.data.ctLine, + realRangeEnd.upperBound - linePosition.range.location, + nil + ) + edgeInsets.left + + // Ensure the fragment has a valid width + guard maxXPos > minXPos else { break } + + // Add the Y positions for the fragment + let topY = linePosition.yPos + fragmentPosition.yPos + fragmentPosition.data.scaledHeight + let bottomY = linePosition.yPos + fragmentPosition.yPos + + // Append points in the correct order + rightSidePoints.append(contentsOf: [ + CGPoint(x: maxXPos, y: bottomY), // Bottom-right + CGPoint(x: maxXPos, y: topY) // Top-right + ]) + leftSidePoints.insert(contentsOf: [ + CGPoint(x: minXPos, y: topY), // Top-left + CGPoint(x: minXPos, y: bottomY) // Bottom-left + ], at: 0) + + // Move to the next fragment + currentOffset = min(validRange.upperBound, linePosition.range.upperBound) + } + + // Combine the points in clockwise order + let points = leftSidePoints + rightSidePoints + + // Close the path + if let firstPoint = points.first { + return NSBezierPath.smoothPath(points + [firstPoint], radius: 2) + } + + return nil + } + // swiftlint:enable function_body_length + /// Finds a suitable cursor rect for the end position. /// - Returns: A CGRect if it could be created. private func rectForEndOffset() -> CGRect? { From 938c9daf7173790d3cadb8ec78c4fb049dba3fde Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 28 Nov 2024 12:00:31 +0100 Subject: [PATCH 05/11] Add EmphasizeAPI class to manage text range emphasis with dynamic highlighting --- .../TextView/TextView+EmphasizeAPI.swift | 180 ++++++++++++++++++ .../CodeEditTextView/TextView/TextView.swift | 4 + 2 files changed, 184 insertions(+) create mode 100644 Sources/CodeEditTextView/TextView/TextView+EmphasizeAPI.swift diff --git a/Sources/CodeEditTextView/TextView/TextView+EmphasizeAPI.swift b/Sources/CodeEditTextView/TextView/TextView+EmphasizeAPI.swift new file mode 100644 index 000000000..ea1c52842 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+EmphasizeAPI.swift @@ -0,0 +1,180 @@ +// +// TextView+EmphasizeAPI.swift +// CodeEditTextView +// +// Created by Tom Ludwig on 05.11.24. +// + +import AppKit + +/// Emphasizes text ranges within a given text view. +public class EmphasizeAPI { + // MARK: - Properties + + private var highlightedRanges: [EmphasizedRange] = [] + private var emphasizedRangeIndex: Int? + private let activeColor: NSColor = NSColor(hex: 0xFFFB00, alpha: 1) + private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) + + weak var textView: TextView? + + init(textView: TextView) { + self.textView = textView + } + + // MARK: - Structs + private struct EmphasizedRange { + var range: NSRange + var layer: CAShapeLayer + } + + // MARK: - Public Methods + + /// Emphasises multiple ranges, with one optionally marked as active (highlighted usually in yellow). + /// + /// - Parameters: + /// - ranges: An array of ranges to highlight. + /// - activeIndex: The index of the range to highlight in yellow. Defaults to `nil`. + /// - clearPrevious: Removes previous emphasised ranges. Defaults to `true`. + public func emphasizeRanges(ranges: [NSRange], activeIndex: Int? = nil, clearPrevious: Bool = true) { + if clearPrevious { + removeEmphasizeLayers() // Clear all existing highlights + } + + ranges.enumerated().forEach { index, range in + let isActive = (index == activeIndex) + emphasizeRange(range: range, active: isActive) + + if isActive { + emphasizedRangeIndex = activeIndex + } + } + } + + /// Emphasises a single range. + /// - Parameters: + /// - range: The text range to highlight. + /// - active: Whether the range should be highlighted as active (usually in yellow). Defaults to `false`. + public func emphasizeRange(range: NSRange, active: Bool = false) { + guard let shapePath = textView?.layoutManager?.roundedPathForRange(range) else { return } + + let layer = createEmphasizeLayer(shapePath: shapePath, active: active) + textView?.layer?.insertSublayer(layer, at: 1) + + highlightedRanges.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 } + + let removedLayer = highlightedRanges[index].layer + removedLayer.removeFromSuperlayer() + + highlightedRanges.remove(at: index) + + // Adjust the active highlight index + if let currentIndex = emphasizedRangeIndex { + if currentIndex == index { + // TODO: What is the desired behaviour here? + emphasizedRangeIndex = nil // Reset if the active highlight is removed + } else if currentIndex > index { + emphasizedRangeIndex = currentIndex - 1 // Shift if the removed index was before the active index + } + } + } + + /// Highlights the previous emphasised range (usually in yellow). + /// + /// - Returns: An optional `NSRange` representing the newly active emphasized range. + /// Returns `nil` if there are no prior ranges to highlight. + @discardableResult + public func highlightPrevious() -> NSRange? { + return shiftActiveHighlight(amount: -1) + } + + /// Highlights the next emphasised range (usually in yellow). + /// + /// - Returns: An optional `NSRange` representing the newly active emphasized range. + /// Returns `nil` if there are no subsequent ranges to highlight. + @discardableResult + public func highlightNext() -> NSRange? { + return shiftActiveHighlight(amount: 1) + } + + /// Removes all emphasised ranges. + public func removeEmphasizeLayers() { + highlightedRanges.forEach { $0.layer.removeFromSuperlayer() } + highlightedRanges.removeAll() + emphasizedRangeIndex = nil + } + + // MARK: - Private Methods + + private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer { + let layer = CAShapeLayer() + layer.cornerRadius = 3.0 + layer.fillColor = (active ? activeColor : inactiveColor).cgColor + layer.shadowColor = .black + layer.shadowOpacity = active ? 0.3 : 0.0 + layer.shadowOffset = CGSize(width: 0, height: 1) + layer.shadowRadius = 3.0 + layer.opacity = 1.0 + + if #available(macOS 14.0, *) { + layer.path = shapePath.cgPath + } else { + layer.path = shapePath.cgPathFallback + } + + return layer + } + + /// Shifts the active highlight to a different emphasized range based on the specified offset. + /// + /// - Parameter amount: The offset to shift the active highlight. + /// - A positive value moves to subsequent ranges. + /// - A negative value moves to prior ranges. + /// + /// - Returns: An optional `NSRange` representing the newly active highlight, colored in the active color. + /// Returns `nil` if no change occurred (e.g., if there are no highlighted ranges). + private func shiftActiveHighlight(amount: Int) -> NSRange? { + guard !highlightedRanges.isEmpty else { return nil } + + var currentIndex = emphasizedRangeIndex ?? -1 + currentIndex = (currentIndex + amount + highlightedRanges.count) % highlightedRanges.count + + guard currentIndex < highlightedRanges.count else { return nil } + + // Reset the previously active layer + if let currentIndex = emphasizedRangeIndex { + let previousLayer = highlightedRanges[currentIndex].layer + previousLayer.fillColor = inactiveColor.cgColor + previousLayer.shadowOpacity = 0.0 + } + + // Set the new active layer + let newLayer = highlightedRanges[currentIndex].layer + newLayer.fillColor = activeColor.cgColor + newLayer.shadowOpacity = 0.3 + + applyPopAnimation(to: newLayer) + emphasizedRangeIndex = currentIndex + + return highlightedRanges[currentIndex].range + } + + private func applyPopAnimation(to layer: CALayer) { + let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") + scaleAnimation.values = [1.0, 1.01, 1.0] + scaleAnimation.keyTimes = [0, 0.5, 1] + scaleAnimation.duration = 0.1 + scaleAnimation.timingFunctions = [ + CAMediaTimingFunction(name: .easeInEaseOut), + CAMediaTimingFunction(name: .easeInEaseOut) + ] + + layer.add(scaleAnimation, forKey: "popAnimation") + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 4c6256128..48fc75c5e 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -225,6 +225,9 @@ public class TextView: NSView, NSTextContent { /// The selection manager for the text view. private(set) public var selectionManager: TextSelectionManager! + /// Empasizse text ranges in the text view + public var emphasizeAPI: EmphasizeAPI? + // MARK: - Private Properties var isFirstResponder: Bool = false @@ -280,6 +283,7 @@ public class TextView: NSView, NSTextContent { super.init(frame: .zero) + self.emphasizeAPI = EmphasizeAPI(textView: self) self.storageDelegate = MultiStorageDelegate() wantsLayer = true From 97ac5e6bc396f52c59f840a4f5bdef437dcb6017 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Sun, 8 Dec 2024 11:46:10 +0100 Subject: [PATCH 06/11] Moved EmphasizeAPI to it's own folder --- .../EmphasizeAPI.swift} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename Sources/CodeEditTextView/{TextView/TextView+EmphasizeAPI.swift => EmphasizeAPI/EmphasizeAPI.swift} (99%) diff --git a/Sources/CodeEditTextView/TextView/TextView+EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift similarity index 99% rename from Sources/CodeEditTextView/TextView/TextView+EmphasizeAPI.swift rename to Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift index ea1c52842..6a57b7ed3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+EmphasizeAPI.swift +++ b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift @@ -1,5 +1,5 @@ // -// TextView+EmphasizeAPI.swift +// EmphasizeAPI.swift // CodeEditTextView // // Created by Tom Ludwig on 05.11.24. From a8e3738615589e1b78096e974956f42558e43360 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Mon, 23 Dec 2024 17:49:17 +0100 Subject: [PATCH 07/11] fixed scale animation --- .../EmphasizeAPI/EmphasizeAPI.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift index 6a57b7ed3..2de61d2d6 100644 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift @@ -128,6 +128,13 @@ public class EmphasizeAPI { layer.path = shapePath.cgPathFallback } + // Set bounds of the layer; needed for the scale animation + if let cgPath = layer.path { + let boundingBox = cgPath.boundingBox + layer.bounds = boundingBox + layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY) + } + return layer } @@ -167,13 +174,10 @@ public class EmphasizeAPI { private func applyPopAnimation(to layer: CALayer) { let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") - scaleAnimation.values = [1.0, 1.01, 1.0] - scaleAnimation.keyTimes = [0, 0.5, 1] - scaleAnimation.duration = 0.1 - scaleAnimation.timingFunctions = [ - CAMediaTimingFunction(name: .easeInEaseOut), - CAMediaTimingFunction(name: .easeInEaseOut) - ] + scaleAnimation.values = [1.0, 1.5, 1.0] + scaleAnimation.keyTimes = [0, 0.3, 1] + scaleAnimation.duration = 0.2 + scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)] layer.add(scaleAnimation, forKey: "popAnimation") } From 3917f61c3bd1be7ba65af47ab6fcb69187ccb901 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 27 Feb 2025 19:12:57 +0100 Subject: [PATCH 08/11] Add scroll to visible range - Scrolls to the specified range and centers it --- .../EmphasizeAPI/EmphasizeAPI.swift | 32 ++++++------- .../TextView/TextView+ScrollToVisible.swift | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 16 deletions(-) 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..f7ea058d4 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,53 @@ extension TextView { } } + public func scrollToRange(_ range: NSRange) { + 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 to center the range in the view + let targetOffset = CGPoint( + x: max(boundingRect.midX - visibleRect.width / 2, 0), + y: max(boundingRect.midY - visibleRect.height / 2, 0) + ) + + var lastFrame: CGRect = .zero + + // Set a timeout to avoid a 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 in the middle of the screen + 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? { From 1d2f62a40ca8097604c943f79bbd121392cd24ec Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 27 Feb 2025 19:18:21 +0100 Subject: [PATCH 09/11] Remove double declaration of emphasizeAPI --- Sources/CodeEditTextView/TextView/TextView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 49a5abfd0..84a0a46d6 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -243,9 +243,6 @@ public class TextView: NSView, NSTextContent { /// Empasizse text ranges in the text view public var emphasizeAPI: EmphasizeAPI? - /// Empasizse text ranges in the text view - public var emphasizeAPI: EmphasizeAPI? - // MARK: - Private Properties var isFirstResponder: Bool = false From c4f2cb199af09ef663f808ff9ce0a9ff7f60ae33 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 27 Feb 2025 19:27:48 +0100 Subject: [PATCH 10/11] Add documentation --- .../TextView/TextView+ScrollToVisible.swift | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index f7ea058d4..f2e12a9bb 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -37,7 +37,14 @@ extension TextView { } } - public func scrollToRange(_ range: NSRange) { + /// 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 } @@ -47,15 +54,23 @@ extension TextView { return // No scrolling needed } - // Calculate the target offset to center the range in the view - let targetOffset = CGPoint( - x: max(boundingRect.midX - visibleRect.width / 2, 0), - y: max(boundingRect.midY - visibleRect.height / 2, 0) - ) + // 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 a infinite loop + // Set a timeout to avoid an infinite loop let timeout: TimeInterval = 0.5 let startTime = Date() @@ -69,7 +84,7 @@ extension TextView { selectionManager.drawSelections(in: visibleRect) } - // Scroll to make the range appear in the middle of the screen + // Scroll to make the range appear at the desired position if lastFrame != .zero { let animated = false // feature flag if animated { From 70a6b8495283ec73038a4772e93935712256b709 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 27 Feb 2025 19:34:35 +0100 Subject: [PATCH 11/11] lint: fix linter --- .../Documents/CodeEditTextViewExampleDocument.swift | 2 +- .../CodeEditTextView/TextView/TextView+ScrollToVisible.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index f2e12a9bb..00475ef9f 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -43,7 +43,8 @@ extension TextView { /// - 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. + /// 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 }