Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Emphasis API for Highlighting Text Ranges #62

Merged
merged 7 commits into from
Dec 28, 2024
Original file line number Diff line number Diff line change
@@ -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
}
}
121 changes: 121 additions & 0 deletions Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift
Original file line number Diff line number Diff line change
@@ -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..<points.count - 1 {
let p0 = points[index - 1]
let p1 = points[index]
let p2 = points[index + 1]

// Calculate vectors
let vector1 = NSPoint(x: p1.x - p0.x, y: p1.y - p0.y)
let vector2 = NSPoint(x: p2.x - p1.x, y: p2.y - p1.y)

// Calculate unit vectors and distances
let distance1 = sqrt(vector1.x * vector1.x + vector1.y * vector1.y)
let distance2 = sqrt(vector2.x * vector2.x + vector2.y * vector2.y)

// TODO: Check if .zero should get used or just skipped
if distance1.isZero || distance2.isZero { continue }
let unitVector1 = distance1 > 0 ? NSPoint(x: vector1.x / distance1, y: vector1.y / distance1) : NSPoint.zero
let unitVector2 = distance2 > 0 ? NSPoint(x: vector2.x / distance2, y: vector2.y / distance2) : NSPoint.zero

// This uses the dot product formula: cos(θ) = (u1 • u2),
// where u1 and u2 are unit vectors. The result will range from -1 to 1:
let angleCosine = unitVector1.x * unitVector2.x + unitVector1.y * unitVector2.y

// If the cosine of the angle is less than 0.5 (i.e., angle > ~60 degrees),
// the radius is reduced to half to avoid overlapping or excessive smoothing.
let clampedRadius = angleCosine < 0.5 ? radius /** 0.5 */: radius // Adjust for sharp angles

// Calculate the corner start and end
let cornerStart = NSPoint(x: p1.x - unitVector1.x * radius, y: p1.y - unitVector1.y * radius)
let cornerEnd = NSPoint(x: p1.x + unitVector2.x * radius, y: p1.y + unitVector2.y * radius)

// 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
}
}
17 changes: 17 additions & 0 deletions Sources/CodeEditTextView/Extensions/NSColor+Hex.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
Loading