Skip to content

Emphasis Manager Enhancements #78

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

Merged
merged 21 commits into from
Apr 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
13e876c
Cleaned up the emphasize highlights appearance. Added black text layer.
austincondiff Mar 13, 2025
3edf6dc
Removed unused changes from last commit.
austincondiff Mar 13, 2025
4cc8772
Enabled anti aliasing and font smoothing. Using findHighlightColor in…
austincondiff Mar 20, 2025
30a4699
Update EmphasizeAPI Colors On Draw
thecoolwinter Mar 25, 2025
66fb2ed
Renamed EmphasisAPI to EmphasisManager. Separated concerns by moving …
austincondiff Mar 28, 2025
d1f56d6
Fix Scrolling To Emphasized Ranges, Swift 6 Warning
thecoolwinter Mar 31, 2025
7032997
Use flatMap
thecoolwinter Mar 31, 2025
e93eaa0
Weakly reference `layer` and `textLayer` in delayed animation
thecoolwinter Mar 31, 2025
1766731
Fix nested `if`
thecoolwinter Mar 31, 2025
dff34d8
Weakly reference `self` in delayed animation
thecoolwinter Mar 31, 2025
05ef1cd
Update docs
thecoolwinter Mar 31, 2025
50081c0
Move `Emphasis` and `EmphasisStyle` to Files
thecoolwinter Mar 31, 2025
475002d
Merge branch 'feat/in-doc-search' of https://github.com/CodeEditApp/C…
thecoolwinter Mar 31, 2025
5bdf601
Remove Extra Closing Bracket
thecoolwinter Mar 31, 2025
365addb
Lint Error
thecoolwinter Mar 31, 2025
49b9a5e
Comment on line drawing
thecoolwinter Apr 3, 2025
2dd0fad
Return Early From `updateSelectionViews` When Not First Responder
thecoolwinter Apr 3, 2025
6eb9f08
Docs, Naming
thecoolwinter Apr 3, 2025
e16c9d1
Docs
thecoolwinter Apr 6, 2025
1495df7
Docs - Spelling
thecoolwinter Apr 6, 2025
5136f8e
Add EmphasisManager Docs
thecoolwinter Apr 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions Sources/CodeEditTextView/EmphasisManager/Emphasis.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// Emphasis.swift
// CodeEditTextView
//
// Created by Khan Winter on 3/31/25.
//

import AppKit

/// Represents a single emphasis with its properties
public struct Emphasis {
/// The range the emphasis applies it's style to, relative to the entire text document.
public let range: NSRange

/// The style to apply emphasis with, handled by the ``EmphasisManager``.
public let style: EmphasisStyle

/// Set to `true` to 'flash' the emphasis before removing it automatically after being added.
///
/// Useful when an emphasis should be temporary and quick, like when emphasizing paired brackets in a document.
public let flash: Bool

/// Set to `true` to style the emphasis as 'inactive'.
///
/// When ``style`` is ``EmphasisStyle/standard``, this reduces shadows and background color.
/// For all styles, if drawing text on top of them, this uses ``EmphasisManager/getInactiveTextColor`` instead of
/// the text view's text color to render the emphasized text.
public let inactive: Bool

/// Set to `true` if the emphasis manager should update the text view's selected range to match
/// this object's ``Emphasis/range`` value.
public let selectInDocument: Bool

public init(
range: NSRange,
style: EmphasisStyle = .standard,
flash: Bool = false,
inactive: Bool = false,
selectInDocument: Bool = false
) {
self.range = range
self.style = style
self.flash = flash
self.inactive = inactive
self.selectInDocument = selectInDocument
}
}
319 changes: 319 additions & 0 deletions Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
//
// EmphasisManager.swift
// CodeEditTextView
//
// Created by Tom Ludwig on 05.11.24.
//

import AppKit

/// Manages text emphases within a text view, supporting multiple styles and groups.
///
/// Text emphasis draws attention to a range of text, indicating importance.
/// This object may be used in a code editor to emphasize search results, or indicate
/// bracket pairs, for instance.
///
/// This object is designed to allow for easy grouping of emphasis types. An outside
/// object is responsible for managing what emphases are visible. Because it's very
/// likely that more than one type of emphasis may occur on the document at the same
/// time, grouping allows each emphasis to be managed separately from the others by
/// each outside object without knowledge of the other's state.
public final class EmphasisManager {
/// Internal representation of a emphasis layer with its associated text layer
private struct EmphasisLayer {
let emphasis: Emphasis
let layer: CAShapeLayer
let textLayer: CATextLayer?
}

private var emphasisGroups: [String: [EmphasisLayer]] = [:]
private let activeColor: NSColor = .findHighlightColor
private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4)
private var originalSelectionColor: NSColor?

weak var textView: TextView?

init(textView: TextView) {
self.textView = textView
}

/// Adds a single emphasis to the specified group.
/// - Parameters:
/// - emphasis: The emphasis to add
/// - id: A group identifier
public func addEmphasis(_ emphasis: Emphasis, for id: String) {
addEmphases([emphasis], for: id)
}

/// Adds multiple emphases to the specified group.
/// - Parameters:
/// - emphases: The emphases to add
/// - id: The group identifier
public func addEmphases(_ emphases: [Emphasis], for id: String) {
// Store the current selection background color if not already stored
if originalSelectionColor == nil {
originalSelectionColor = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor
}

let layers = emphases.map { createEmphasisLayer(for: $0) }
emphasisGroups[id] = layers

// Handle selections
handleSelections(for: emphases)

// Handle flash animations
for (index, emphasis) in emphases.enumerated() where emphasis.flash {
let layer = layers[index]
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self = self else { return }
self.applyFadeOutAnimation(to: layer.layer, textLayer: layer.textLayer)
// Remove the emphasis from the group
if var emphases = self.emphasisGroups[id] {
emphases.remove(at: index)
if emphases.isEmpty {
self.emphasisGroups.removeValue(forKey: id)
} else {
self.emphasisGroups[id] = emphases
}
}
}
}
}

/// Replaces all emphases in the specified group.
/// - Parameters:
/// - emphases: The new emphases
/// - id: The group identifier
public func replaceEmphases(_ emphases: [Emphasis], for id: String) {
removeEmphases(for: id)
addEmphases(emphases, for: id)
}

/// Updates the emphases for a group by transforming the existing array.
/// - Parameters:
/// - id: The group identifier
/// - transform: The transformation to apply to the existing emphases
public func updateEmphases(for id: String, _ transform: ([Emphasis]) -> [Emphasis]) {
guard let existingLayers = emphasisGroups[id] else { return }
let existingEmphases = existingLayers.map { $0.emphasis }
let newEmphases = transform(existingEmphases)
replaceEmphases(newEmphases, for: id)
}

/// Removes all emphases for the given group.
/// - Parameter id: The group identifier
public func removeEmphases(for id: String) {
emphasisGroups[id]?.forEach { layer in
layer.layer.removeAllAnimations()
layer.layer.removeFromSuperlayer()
layer.textLayer?.removeAllAnimations()
layer.textLayer?.removeFromSuperlayer()
}
emphasisGroups[id] = nil
}

/// Removes all emphases for all groups.
public func removeAllEmphases() {
emphasisGroups.keys.forEach { removeEmphases(for: $0) }
emphasisGroups.removeAll()

// Restore original selection emphasising
if let originalColor = originalSelectionColor {
textView?.selectionManager.selectionBackgroundColor = originalColor
}
originalSelectionColor = nil
}

/// Gets all emphases for a given group.
/// - Parameter id: The group identifier
/// - Returns: Array of emphases in the group
public func getEmphases(for id: String) -> [Emphasis] {
emphasisGroups[id]?.map { $0.emphasis } ?? []
}

/// Updates the positions and bounds of all emphasis layers to match the current text layout.
public func updateLayerBackgrounds() {
for layer in emphasisGroups.flatMap(\.value) {
if let shapePath = textView?.layoutManager?.roundedPathForRange(layer.emphasis.range) {
if #available(macOS 14.0, *) {
layer.layer.path = shapePath.cgPath
} else {
layer.layer.path = shapePath.cgPathFallback
}

// Update bounds and position
if let cgPath = layer.layer.path {
let boundingBox = cgPath.boundingBox
layer.layer.bounds = boundingBox
layer.layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY)
}

// Update text layer if it exists
if let textLayer = layer.textLayer {
var bounds = shapePath.bounds
bounds.origin.y += 1 // Move down by 1 pixel
textLayer.frame = bounds
}
}
}
}

private func createEmphasisLayer(for emphasis: Emphasis) -> EmphasisLayer {
guard let shapePath = textView?.layoutManager?.roundedPathForRange(emphasis.range) else {
return EmphasisLayer(emphasis: emphasis, layer: CAShapeLayer(), textLayer: nil)
}

let layer = createShapeLayer(shapePath: shapePath, emphasis: emphasis)
textView?.layer?.insertSublayer(layer, at: 1)

let textLayer = createTextLayer(for: emphasis)
if let textLayer = textLayer {
textView?.layer?.addSublayer(textLayer)
}

if emphasis.inactive == false && emphasis.style == .standard {
applyPopAnimation(to: layer)
}

return EmphasisLayer(emphasis: emphasis, layer: layer, textLayer: textLayer)
}

private func createShapeLayer(shapePath: NSBezierPath, emphasis: Emphasis) -> CAShapeLayer {
let layer = CAShapeLayer()

switch emphasis.style {
case .standard:
layer.cornerRadius = 4.0
layer.fillColor = (emphasis.inactive ? inactiveColor : activeColor).cgColor
layer.shadowColor = .black
layer.shadowOpacity = emphasis.inactive ? 0.0 : 0.5
layer.shadowOffset = CGSize(width: 0, height: 1.5)
layer.shadowRadius = 1.5
layer.opacity = 1.0
layer.zPosition = emphasis.inactive ? 0 : 1
case .underline(let color):
layer.lineWidth = 1.0
layer.lineCap = .round
layer.strokeColor = color.cgColor
layer.fillColor = nil
layer.opacity = emphasis.flash ? 0.0 : 1.0
layer.zPosition = 1
case .outline(let color):
layer.cornerRadius = 2.5
layer.borderColor = color.cgColor
layer.borderWidth = 0.5
layer.fillColor = nil
layer.opacity = emphasis.flash ? 0.0 : 1.0
layer.zPosition = 1
}

if #available(macOS 14.0, *) {
layer.path = shapePath.cgPath
} else {
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
}

private func createTextLayer(for emphasis: Emphasis) -> CATextLayer? {
guard let textView = textView,
let layoutManager = textView.layoutManager,
let shapePath = layoutManager.roundedPathForRange(emphasis.range),
let originalString = textView.textStorage?.attributedSubstring(from: emphasis.range) else {
return nil
}

var bounds = shapePath.bounds
bounds.origin.y += 1 // Move down by 1 pixel

// Create text layer
let textLayer = CATextLayer()
textLayer.frame = bounds
textLayer.backgroundColor = NSColor.clear.cgColor
textLayer.contentsScale = textView.window?.screen?.backingScaleFactor ?? 2.0
textLayer.allowsFontSubpixelQuantization = true
textLayer.zPosition = 2

// Get the font from the attributed string
if let font = originalString.attribute(.font, at: 0, effectiveRange: nil) as? NSFont {
textLayer.font = font
} else {
textLayer.font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
}

updateTextLayer(textLayer, with: originalString, emphasis: emphasis)
return textLayer
}

private func updateTextLayer(
_ textLayer: CATextLayer,
with originalString: NSAttributedString,
emphasis: Emphasis
) {
let text = NSMutableAttributedString(attributedString: originalString)
text.addAttribute(
.foregroundColor,
value: emphasis.inactive ? getInactiveTextColor() : NSColor.black,
range: NSRange(location: 0, length: text.length)
)
textLayer.string = text
}

private func getInactiveTextColor() -> NSColor {
if textView?.effectiveAppearance.name == .darkAqua {
return .white
}
return .black
}

private func applyPopAnimation(to layer: CALayer) {
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
scaleAnimation.values = [1.0, 1.25, 1.0]
scaleAnimation.keyTimes = [0, 0.3, 1]
scaleAnimation.duration = 0.1
scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)]

layer.add(scaleAnimation, forKey: "popAnimation")
}

private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?) {
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
fadeAnimation.fromValue = 1.0
fadeAnimation.toValue = 0.0
fadeAnimation.duration = 0.1
fadeAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
fadeAnimation.fillMode = .forwards
fadeAnimation.isRemovedOnCompletion = false

layer.add(fadeAnimation, forKey: "fadeOutAnimation")

if let textLayer = textLayer, let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation {
textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation")
textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation")
}

// Remove both layers after animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { [weak layer, weak textLayer] in
layer?.removeFromSuperlayer()
textLayer?.removeFromSuperlayer()
}
}

/// Handles selection of text ranges for emphases where select is true
private func handleSelections(for emphases: [Emphasis]) {
let selectableRanges = emphases.filter(\.selectInDocument).map(\.range)
guard let textView, !selectableRanges.isEmpty else { return }

textView.selectionManager.setSelectedRanges(selectableRanges)
textView.scrollSelectionToVisible()
textView.needsDisplay = true
}
}
31 changes: 31 additions & 0 deletions Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// EmphasisStyle.swift
// CodeEditTextView
//
// Created by Khan Winter on 3/31/25.
//

import AppKit

/// Defines the style of emphasis to apply to text ranges
public enum EmphasisStyle: Equatable {
/// Standard emphasis with background color
case standard
/// Underline emphasis with a line color
case underline(color: NSColor)
/// Outline emphasis with a border color
case outline(color: NSColor)

public static func == (lhs: EmphasisStyle, rhs: EmphasisStyle) -> Bool {
switch (lhs, rhs) {
case (.standard, .standard):
return true
case (.underline(let lhsColor), .underline(let rhsColor)):
return lhsColor == rhsColor
case (.outline(let lhsColor), .outline(let rhsColor)):
return lhsColor == rhsColor
default:
return false
}
}
}
Loading