From 13e876cf7751fe71183afdf13a7f60c15ac5d3bc Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 13 Mar 2025 10:48:32 -0500 Subject: [PATCH 01/20] Cleaned up the emphasize highlights appearance. Added black text layer. --- .../EmphasizeAPI/EmphasizeAPI.swift | 116 +++++++++++++++--- .../TextLayoutManager+Public.swift | 2 +- .../CodeEditTextView/TextView/TextView.swift | 29 +++++ 3 files changed, 130 insertions(+), 17 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift index 0ed8a5716..771af8852 100644 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift @@ -15,6 +15,8 @@ public class EmphasizeAPI { public private(set) var emphasizedRangeIndex: Int? private let activeColor: NSColor = NSColor(hex: 0xFFFB00, alpha: 1) private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) + private var activeTextLayer: CATextLayer? + private var originalSelectionColor: NSColor? weak var textView: TextView? @@ -26,27 +28,36 @@ public class EmphasizeAPI { public struct EmphasizedRange { public var range: NSRange var layer: CAShapeLayer + var textLayer: CATextLayer? } // MARK: - Public Methods - /// Emphasises multiple ranges, with one optionally marked as active (highlighted usually in yellow). + /// Emphasises multiple ranges, with one optionally marked as active (highlighted in yellow with black text). /// /// - Parameters: /// - ranges: An array of ranges to highlight. - /// - activeIndex: The index of the range to highlight in yellow. Defaults to `nil`. + /// - activeIndex: The index of the range to highlight. 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 + removeEmphasizeLayers() } + // Store the current selection background color if not already stored + if originalSelectionColor == nil { + originalSelectionColor = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor + } + // Temporarily disable selection highlighting + textView?.selectionManager.selectionBackgroundColor = .clear + ranges.enumerated().forEach { index, range in let isActive = (index == activeIndex) emphasizeRange(range: range, active: isActive) if isActive { emphasizedRangeIndex = activeIndex + setTextColorForRange(range, active: true) } } } @@ -54,14 +65,20 @@ public class EmphasizeAPI { /// 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`. + /// - active: Whether the range should be highlighted as active (black text). 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) - - emphasizedRanges.append(EmphasizedRange(range: range, layer: layer)) + + // Create and add text layer + if let textLayer = createTextLayer(for: range, active: active) { + textView?.layer?.addSublayer(textLayer) + emphasizedRanges.append(EmphasizedRange(range: range, layer: layer, textLayer: textLayer)) + } else { + emphasizedRanges.append(EmphasizedRange(range: range, layer: layer, textLayer: nil)) + } } /// Removes the highlight for a specific range. @@ -71,16 +88,18 @@ public class EmphasizeAPI { let removedLayer = emphasizedRanges[index].layer removedLayer.removeFromSuperlayer() + + // Remove text layer + emphasizedRanges[index].textLayer?.removeFromSuperlayer() emphasizedRanges.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 + emphasizedRangeIndex = nil } else if currentIndex > index { - emphasizedRangeIndex = currentIndex - 1 // Shift if the removed index was before the active index + emphasizedRangeIndex = currentIndex - 1 } } } @@ -105,22 +124,34 @@ public class EmphasizeAPI { /// Removes all emphasised ranges. public func removeEmphasizeLayers() { - emphasizedRanges.forEach { $0.layer.removeFromSuperlayer() } + emphasizedRanges.forEach { range in + range.layer.removeFromSuperlayer() + range.textLayer?.removeFromSuperlayer() + } emphasizedRanges.removeAll() emphasizedRangeIndex = nil + + // Restore original selection highlighting + if let originalColor = originalSelectionColor { + textView?.selectionManager.selectionBackgroundColor = originalColor + } + + // Force a redraw to ensure colors update + textView?.needsDisplay = true } // MARK: - Private Methods private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer { let layer = CAShapeLayer() - layer.cornerRadius = 3.0 + layer.cornerRadius = 4.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.shadowOpacity = active ? 0.5 : 0.0 + layer.shadowOffset = CGSize(width: 0, height: 1.5) + layer.shadowRadius = 1.5 layer.opacity = 1.0 + layer.zPosition = active ? 1 : 0 if #available(macOS 14.0, *) { layer.path = shapePath.cgPath @@ -154,17 +185,19 @@ public class EmphasizeAPI { guard currentIndex < emphasizedRanges.count else { return nil } - // Reset the previously active layer + // Reset the previously active layer and text color if let currentIndex = emphasizedRangeIndex { let previousLayer = emphasizedRanges[currentIndex].layer previousLayer.fillColor = inactiveColor.cgColor previousLayer.shadowOpacity = 0.0 + setTextColorForRange(emphasizedRanges[currentIndex].range, active: false) } - // Set the new active layer + // Set the new active layer and text color let newLayer = emphasizedRanges[currentIndex].layer newLayer.fillColor = activeColor.cgColor newLayer.shadowOpacity = 0.3 + setTextColorForRange(emphasizedRanges[currentIndex].range, active: true) applyPopAnimation(to: newLayer) emphasizedRangeIndex = currentIndex @@ -181,4 +214,55 @@ public class EmphasizeAPI { layer.add(scaleAnimation, forKey: "popAnimation") } + + private func getInactiveTextColor() -> NSColor { + if textView?.effectiveAppearance.name == .darkAqua { + return .white + } + return .black + } + + private func createTextLayer(for range: NSRange, active: Bool) -> CATextLayer? { + guard let textView = textView, + let layoutManager = textView.layoutManager, + let shapePath = layoutManager.roundedPathForRange(range), + let originalString = textView.textStorage?.attributedSubstring(from: 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, active: active) + return textLayer + } + + private func updateTextLayer(_ textLayer: CATextLayer, with originalString: NSAttributedString, active: Bool) { + let text = NSMutableAttributedString(attributedString: originalString) + text.addAttribute(.foregroundColor, + value: active ? NSColor.black : getInactiveTextColor(), + range: NSRange(location: 0, length: text.length)) + textLayer.string = text + } + + private func setTextColorForRange(_ range: NSRange, active: Bool) { + guard let index = emphasizedRanges.firstIndex(where: { $0.range == range }), + let textLayer = emphasizedRanges[index].textLayer, + let originalString = textView?.textStorage?.attributedSubstring(from: range) else { return } + + updateTextLayer(textLayer, with: originalString, active: active) + } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 34617613d..4a849742e 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -234,7 +234,7 @@ extension TextLayoutManager { // Close the path if let firstPoint = points.first { - return NSBezierPath.smoothPath(points + [firstPoint], radius: 2) + return NSBezierPath.smoothPath(points + [firstPoint], radius: 4) } return nil diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 84a0a46d6..d1e1c77c0 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -213,6 +213,34 @@ public class TextView: NSView, NSTextContent { } } + /// Whether the text view should use a custom background color + private var useCustomBackground: Bool = false + + /// The background color to use when useCustomBackground is true + private var customBackgroundColor: NSColor = .textBackgroundColor { + didSet { + updateBackgroundColor() + } + } + + /// Sets the background color of the text view + /// - Parameters: + /// - color: The color to use for the background + /// - useCustom: Whether to use the custom color (true) or system background (false) + public func setBackgroundColor(_ color: NSColor, useCustom: Bool) { + useCustomBackground = useCustom + customBackgroundColor = color + updateBackgroundColor() + } + + private func updateBackgroundColor() { + if useCustomBackground { + layer?.backgroundColor = customBackgroundColor.cgColor + } else { + layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + } + } + /// The attributes used to render marked text. /// Defaults to a single underline. public var markedTextAttributes: [NSAttributedString.Key: Any] { @@ -323,6 +351,7 @@ public class TextView: NSView, NSTextContent { layoutManager.layoutLines() setUpDragGesture() + layer?.backgroundColor = .clear } required init?(coder: NSCoder) { From 3edf6dca786b1b69cd57902c747f2c248d36771d Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 13 Mar 2025 10:56:20 -0500 Subject: [PATCH 02/20] Removed unused changes from last commit. --- .../CodeEditTextView/TextView/TextView.swift | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index d1e1c77c0..84a0a46d6 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -213,34 +213,6 @@ public class TextView: NSView, NSTextContent { } } - /// Whether the text view should use a custom background color - private var useCustomBackground: Bool = false - - /// The background color to use when useCustomBackground is true - private var customBackgroundColor: NSColor = .textBackgroundColor { - didSet { - updateBackgroundColor() - } - } - - /// Sets the background color of the text view - /// - Parameters: - /// - color: The color to use for the background - /// - useCustom: Whether to use the custom color (true) or system background (false) - public func setBackgroundColor(_ color: NSColor, useCustom: Bool) { - useCustomBackground = useCustom - customBackgroundColor = color - updateBackgroundColor() - } - - private func updateBackgroundColor() { - if useCustomBackground { - layer?.backgroundColor = customBackgroundColor.cgColor - } else { - layer?.backgroundColor = NSColor.textBackgroundColor.cgColor - } - } - /// The attributes used to render marked text. /// Defaults to a single underline. public var markedTextAttributes: [NSAttributedString.Key: Any] { @@ -351,7 +323,6 @@ public class TextView: NSView, NSTextContent { layoutManager.layoutLines() setUpDragGesture() - layer?.backgroundColor = .clear } required init?(coder: NSCoder) { From 4cc8772d15880b1b29731e0760c29eb23b6b2d90 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 20 Mar 2025 02:53:14 -0500 Subject: [PATCH 03/20] Enabled anti aliasing and font smoothing. Using findHighlightColor instead of a custom color. --- Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift | 8 ++++---- Sources/CodeEditTextView/TextLine/LineFragmentView.swift | 9 --------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift index 771af8852..3f98b373c 100644 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift @@ -13,7 +13,7 @@ public class EmphasizeAPI { public private(set) var emphasizedRanges: [EmphasizedRange] = [] public private(set) var emphasizedRangeIndex: Int? - private let activeColor: NSColor = NSColor(hex: 0xFFFB00, alpha: 1) + private let activeColor: NSColor = .findHighlightColor private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) private var activeTextLayer: CATextLayer? private var originalSelectionColor: NSColor? @@ -88,7 +88,7 @@ public class EmphasizeAPI { let removedLayer = emphasizedRanges[index].layer removedLayer.removeFromSuperlayer() - + // Remove text layer emphasizedRanges[index].textLayer?.removeFromSuperlayer() @@ -130,12 +130,12 @@ public class EmphasizeAPI { } emphasizedRanges.removeAll() emphasizedRangeIndex = nil - + // Restore original selection highlighting if let originalColor = originalSelectionColor { textView?.selectionManager.selectionBackgroundColor = originalColor } - + // Force a redraw to ensure colors update textView?.needsDisplay = true } diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 043c1829e..a97d4e203 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -42,15 +42,6 @@ final class LineFragmentView: NSView { } context.saveGState() - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - context.setAllowsFontSmoothing(false) - context.setShouldSmoothFonts(false) - context.setAllowsFontSubpixelPositioning(true) - context.setShouldSubpixelPositionFonts(true) - context.setAllowsFontSubpixelQuantization(true) - context.setShouldSubpixelQuantizeFonts(true) - ContextSetHiddenSmoothingStyle(context, 16) context.textMatrix = .init(scaleX: 1, y: -1) From 30a469978b096b942a9c8428a2b0c933e7b7741e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:30:14 -0500 Subject: [PATCH 04/20] Update EmphasizeAPI Colors On Draw --- .../EmphasizeAPI/EmphasizeAPI.swift | 34 +++++++++++++++---- .../TextView/TextView+Layout.swift | 1 + 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift index 3f98b373c..827cb0964 100644 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift @@ -25,6 +25,7 @@ public class EmphasizeAPI { } // MARK: - Structs + public struct EmphasizedRange { public var range: NSRange var layer: CAShapeLayer @@ -71,7 +72,7 @@ public class EmphasizeAPI { let layer = createEmphasizeLayer(shapePath: shapePath, active: active) textView?.layer?.insertSublayer(layer, at: 1) - + // Create and add text layer if let textLayer = createTextLayer(for: range, active: active) { textView?.layer?.addSublayer(textLayer) @@ -140,6 +141,21 @@ public class EmphasizeAPI { textView?.needsDisplay = true } + package func updateLayerBackgrounds() { + emphasizedRanges.enumerated().forEach { (idx, range) in + let isActive = emphasizedRangeIndex == idx + range.layer.fillColor = (isActive ? activeColor : inactiveColor).cgColor + + guard let attributedString = range.textLayer?.string as? NSAttributedString else { return } + let mutableString = NSMutableAttributedString(attributedString: attributedString) + mutableString.addAttributes( + [.foregroundColor: isActive ? NSColor.black : getInactiveTextColor()], + range: NSRange(location: 0, length: range.range.length) + ) + range.textLayer?.string = mutableString + } + } + // MARK: - Private Methods private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer { @@ -226,7 +242,9 @@ public class EmphasizeAPI { guard let textView = textView, let layoutManager = textView.layoutManager, let shapePath = layoutManager.roundedPathForRange(range), - let originalString = textView.textStorage?.attributedSubstring(from: range) else { return nil } + let originalString = textView.textStorage?.attributedSubstring(from: range) else { + return nil + } var bounds = shapePath.bounds bounds.origin.y += 1 // Move down by 1 pixel @@ -252,16 +270,20 @@ public class EmphasizeAPI { private func updateTextLayer(_ textLayer: CATextLayer, with originalString: NSAttributedString, active: Bool) { let text = NSMutableAttributedString(attributedString: originalString) - text.addAttribute(.foregroundColor, - value: active ? NSColor.black : getInactiveTextColor(), - range: NSRange(location: 0, length: text.length)) + text.addAttribute( + .foregroundColor, + value: active ? NSColor.black : getInactiveTextColor(), + range: NSRange(location: 0, length: text.length) + ) textLayer.string = text } private func setTextColorForRange(_ range: NSRange, active: Bool) { guard let index = emphasizedRanges.firstIndex(where: { $0.range == range }), let textLayer = emphasizedRanges[index].textLayer, - let originalString = textView?.textStorage?.attributedSubstring(from: range) else { return } + let originalString = textView?.textStorage?.attributedSubstring(from: range) else { + return + } updateTextLayer(textLayer, with: originalString, active: active) } diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 038dd349a..3ef50d120 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -22,6 +22,7 @@ extension TextView { if isSelectable { selectionManager.drawSelections(in: dirtyRect) } + emphasizeAPI?.updateLayerBackgrounds() } override open var isFlipped: Bool { From 66fb2ed473e62577737bf0393a483732983bb066 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 27 Mar 2025 21:49:00 -0500 Subject: [PATCH 05/20] Renamed EmphasisAPI to EmphasisManager. Separated concerns by moving match cycle logic from EmphasisManager to FindViewController. Using EmphasisManager in bracket pair matching instead of custom implementation reducing duplicated code. Implemented flash find matches when clicking the next and previous buttons when the editor is in focus. `bracketPairHighlight` becomes `bracketPairEmphasis`. Fixed various find issues and cleaned up implementation. --- .../EmphasisManager/EmphasisManager.swift | 362 ++++++++++++++++++ .../EmphasizeAPI/EmphasizeAPI.swift | 290 -------------- .../TextSelectionManager.swift | 21 +- .../TextView/TextView+Layout.swift | 2 +- .../CodeEditTextView/TextView/TextView.swift | 6 +- 5 files changed, 375 insertions(+), 306 deletions(-) create mode 100644 Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift delete mode 100644 Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift new file mode 100644 index 000000000..3920d00d5 --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -0,0 +1,362 @@ +// +// EmphasisManager.swift +// CodeEditTextView +// +// Created by Tom Ludwig on 05.11.24. +// + +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 + } + } +} + +/// Represents a single emphasis with its properties +public struct Emphasis { + public let range: NSRange + public let style: EmphasisStyle + public let flash: Bool + public let inactive: Bool + public let select: Bool + + public init( + range: NSRange, + style: EmphasisStyle = .standard, + flash: Bool = false, + inactive: Bool = false, + select: Bool = false + ) { + self.range = range + self.style = style + self.flash = flash + self.inactive = inactive + self.select = select + } +} + +/// Manages text emphases within a text view, supporting multiple styles and groups. +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: The 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) { + 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 (_, layers) in emphasisGroups { + for layer in layers { + 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 { + if let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation { + textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") + } + } + + // Remove both layers after animation completes + DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { + 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(\.select).map(\.range) + guard let textView, !selectableRanges.isEmpty else { return } + + textView.selectionManager.setSelectedRanges(selectableRanges) + + // Scroll to the first selected range + if let firstRange = selectableRanges.first { + textView.scrollToRange(firstRange) + } + + textView.needsDisplay = true + } +} diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift deleted file mode 100644 index 827cb0964..000000000 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ /dev/null @@ -1,290 +0,0 @@ -// -// 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 - - public private(set) var emphasizedRanges: [EmphasizedRange] = [] - public private(set) var emphasizedRangeIndex: Int? - private let activeColor: NSColor = .findHighlightColor - private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) - private var activeTextLayer: CATextLayer? - private var originalSelectionColor: NSColor? - - weak var textView: TextView? - - init(textView: TextView) { - self.textView = textView - } - - // MARK: - Structs - - public struct EmphasizedRange { - public var range: NSRange - var layer: CAShapeLayer - var textLayer: CATextLayer? - } - - // MARK: - Public Methods - - /// Emphasises multiple ranges, with one optionally marked as active (highlighted in yellow with black text). - /// - /// - Parameters: - /// - ranges: An array of ranges to highlight. - /// - activeIndex: The index of the range to highlight. 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() - } - - // Store the current selection background color if not already stored - if originalSelectionColor == nil { - originalSelectionColor = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor - } - // Temporarily disable selection highlighting - textView?.selectionManager.selectionBackgroundColor = .clear - - ranges.enumerated().forEach { index, range in - let isActive = (index == activeIndex) - emphasizeRange(range: range, active: isActive) - - if isActive { - emphasizedRangeIndex = activeIndex - setTextColorForRange(range, active: true) - } - } - } - - /// Emphasises a single range. - /// - Parameters: - /// - range: The text range to highlight. - /// - active: Whether the range should be highlighted as active (black text). 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) - - // Create and add text layer - if let textLayer = createTextLayer(for: range, active: active) { - textView?.layer?.addSublayer(textLayer) - emphasizedRanges.append(EmphasizedRange(range: range, layer: layer, textLayer: textLayer)) - } else { - emphasizedRanges.append(EmphasizedRange(range: range, layer: layer, textLayer: nil)) - } - } - - /// Removes the highlight for a specific range. - /// - Parameter range: The range to remove. - public func removeHighlightForRange(_ range: NSRange) { - guard let index = emphasizedRanges.firstIndex(where: { $0.range == range }) else { return } - - let removedLayer = emphasizedRanges[index].layer - removedLayer.removeFromSuperlayer() - - // Remove text layer - emphasizedRanges[index].textLayer?.removeFromSuperlayer() - - emphasizedRanges.remove(at: index) - - // Adjust the active highlight index - if let currentIndex = emphasizedRangeIndex { - if currentIndex == index { - emphasizedRangeIndex = nil - } else if currentIndex > index { - emphasizedRangeIndex = currentIndex - 1 - } - } - } - - /// 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() { - emphasizedRanges.forEach { range in - range.layer.removeFromSuperlayer() - range.textLayer?.removeFromSuperlayer() - } - emphasizedRanges.removeAll() - emphasizedRangeIndex = nil - - // Restore original selection highlighting - if let originalColor = originalSelectionColor { - textView?.selectionManager.selectionBackgroundColor = originalColor - } - - // Force a redraw to ensure colors update - textView?.needsDisplay = true - } - - package func updateLayerBackgrounds() { - emphasizedRanges.enumerated().forEach { (idx, range) in - let isActive = emphasizedRangeIndex == idx - range.layer.fillColor = (isActive ? activeColor : inactiveColor).cgColor - - guard let attributedString = range.textLayer?.string as? NSAttributedString else { return } - let mutableString = NSMutableAttributedString(attributedString: attributedString) - mutableString.addAttributes( - [.foregroundColor: isActive ? NSColor.black : getInactiveTextColor()], - range: NSRange(location: 0, length: range.range.length) - ) - range.textLayer?.string = mutableString - } - } - - // MARK: - Private Methods - - private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer { - let layer = CAShapeLayer() - layer.cornerRadius = 4.0 - layer.fillColor = (active ? activeColor : inactiveColor).cgColor - layer.shadowColor = .black - layer.shadowOpacity = active ? 0.5 : 0.0 - layer.shadowOffset = CGSize(width: 0, height: 1.5) - layer.shadowRadius = 1.5 - layer.opacity = 1.0 - layer.zPosition = active ? 1 : 0 - - 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 - } - - /// 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 !emphasizedRanges.isEmpty else { return nil } - - var currentIndex = emphasizedRangeIndex ?? -1 - currentIndex = (currentIndex + amount + emphasizedRanges.count) % emphasizedRanges.count - - guard currentIndex < emphasizedRanges.count else { return nil } - - // Reset the previously active layer and text color - if let currentIndex = emphasizedRangeIndex { - let previousLayer = emphasizedRanges[currentIndex].layer - previousLayer.fillColor = inactiveColor.cgColor - previousLayer.shadowOpacity = 0.0 - setTextColorForRange(emphasizedRanges[currentIndex].range, active: false) - } - - // Set the new active layer and text color - let newLayer = emphasizedRanges[currentIndex].layer - newLayer.fillColor = activeColor.cgColor - newLayer.shadowOpacity = 0.3 - setTextColorForRange(emphasizedRanges[currentIndex].range, active: true) - - applyPopAnimation(to: newLayer) - emphasizedRangeIndex = currentIndex - - return emphasizedRanges[currentIndex].range - } - - private func applyPopAnimation(to layer: CALayer) { - let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") - 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") - } - - private func getInactiveTextColor() -> NSColor { - if textView?.effectiveAppearance.name == .darkAqua { - return .white - } - return .black - } - - private func createTextLayer(for range: NSRange, active: Bool) -> CATextLayer? { - guard let textView = textView, - let layoutManager = textView.layoutManager, - let shapePath = layoutManager.roundedPathForRange(range), - let originalString = textView.textStorage?.attributedSubstring(from: 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, active: active) - return textLayer - } - - private func updateTextLayer(_ textLayer: CATextLayer, with originalString: NSAttributedString, active: Bool) { - let text = NSMutableAttributedString(attributedString: originalString) - text.addAttribute( - .foregroundColor, - value: active ? NSColor.black : getInactiveTextColor(), - range: NSRange(location: 0, length: text.length) - ) - textLayer.string = text - } - - private func setTextColorForRange(_ range: NSRange, active: Bool) { - guard let index = emphasizedRanges.firstIndex(where: { $0.range == range }), - let textLayer = emphasizedRanges[index].textLayer, - let originalString = textView?.textStorage?.attributedSubstring(from: range) else { - return - } - - updateTextLayer(textLayer, with: originalString, active: active) - } -} diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index b6311a9ab..03388a162 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -78,10 +78,9 @@ public class TextSelectionManager: NSObject { let selection = TextSelection(range: range) selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX textSelections = [selection] - if textView?.isFirstResponder ?? false { - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + delegate?.setNeedsDisplay() } /// Set the selected ranges to new ranges. Overrides any existing selections. @@ -99,10 +98,9 @@ public class TextSelectionManager: NSObject { selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX return selection } - if textView?.isFirstResponder ?? false { - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + delegate?.setNeedsDisplay() } /// Append a new selected range to the existing ones. @@ -126,10 +124,9 @@ public class TextSelectionManager: NSObject { textSelections.append(newTextSelection) } - if textView?.isFirstResponder ?? false { - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + delegate?.setNeedsDisplay() } // MARK: - Selection Views diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 3ef50d120..6f9a0067b 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -22,7 +22,7 @@ extension TextView { if isSelectable { selectionManager.drawSelections(in: dirtyRect) } - emphasizeAPI?.updateLayerBackgrounds() + emphasisManager?.updateLayerBackgrounds() } override open var isFlipped: Bool { diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 84a0a46d6..ddecd8ba3 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -240,8 +240,8 @@ public class TextView: NSView, NSTextContent { /// The selection manager for the text view. package(set) public var selectionManager: TextSelectionManager! - /// Empasizse text ranges in the text view - public var emphasizeAPI: EmphasizeAPI? + /// Manages text highlights within the text view + public var emphasisManager: EmphasisManager? // MARK: - Private Properties @@ -298,7 +298,7 @@ public class TextView: NSView, NSTextContent { super.init(frame: .zero) - self.emphasizeAPI = EmphasizeAPI(textView: self) + self.emphasisManager = EmphasisManager(textView: self) self.storageDelegate = MultiStorageDelegate() wantsLayer = true From d1f56d66f72425e4693c9cad4265112f4b092987 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:11:40 -0500 Subject: [PATCH 06/20] Fix Scrolling To Emphasized Ranges, Swift 6 Warning --- .../CodeEditTextView/EmphasisManager/EmphasisManager.swift | 7 +------ .../Extensions/NSBezierPath+CGPathFallback.swift | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index 3920d00d5..0c7b4d23f 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -351,12 +351,7 @@ public final class EmphasisManager { guard let textView, !selectableRanges.isEmpty else { return } textView.selectionManager.setSelectedRanges(selectableRanges) - - // Scroll to the first selected range - if let firstRange = selectableRanges.first { - textView.scrollToRange(firstRange) - } - + textView.scrollSelectionToVisible() textView.needsDisplay = true } } diff --git a/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift index dcb1a7121..a174185d6 100644 --- a/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift +++ b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift @@ -24,7 +24,7 @@ extension NSBezierPath { path.addCurve(to: points[2], control1: points[0], control2: points[1]) case .closePath: path.closeSubpath() - @unknown default: + default: continue } } From 703299714e47bcc4a1bd14c3b1c514184547ee86 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:22:17 -0500 Subject: [PATCH 07/20] Use flatMap --- .../EmphasisManager/EmphasisManager.swift | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index 0c7b4d23f..1b4463f69 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -168,28 +168,26 @@ public final class EmphasisManager { /// Updates the positions and bounds of all emphasis layers to match the current text layout. public func updateLayerBackgrounds() { - for (_, layers) in emphasisGroups { - for layer in layers { - 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 - } + 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 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 - } + // 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 } } } From e93eaa05f7db391c27b076022ca7413272f7f507 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:22:37 -0500 Subject: [PATCH 08/20] Weakly reference `layer` and `textLayer` in delayed animation --- .../CodeEditTextView/EmphasisManager/EmphasisManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index 1b4463f69..2bfb40f2e 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -337,8 +337,8 @@ public final class EmphasisManager { } // Remove both layers after animation completes - DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { - layer.removeFromSuperlayer() + DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { [weak layer, weak textLayer] in + layer?.removeFromSuperlayer() textLayer?.removeFromSuperlayer() } } From 1766731ed3da95a2104b4c6500db280242624f59 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:23:02 -0500 Subject: [PATCH 09/20] Fix nested `if` --- .../CodeEditTextView/EmphasisManager/EmphasisManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index 2bfb40f2e..cb5e95493 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -330,8 +330,8 @@ public final class EmphasisManager { layer.add(fadeAnimation, forKey: "fadeOutAnimation") - if let textLayer = textLayer { - if let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation { + if let textLayer = textLayer, let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation { + textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") } } From dff34d8c9be84f98c3630ee5d17ffedc6c08f582 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:23:18 -0500 Subject: [PATCH 10/20] Weakly reference `self` in delayed animation --- Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index cb5e95493..56f6af0b8 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -100,7 +100,8 @@ public final class EmphasisManager { // Handle flash animations for (index, emphasis) in emphases.enumerated() where emphasis.flash { let layer = layers[index] - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + 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] { From 05ef1cdb65581f65cd8ace0804a4f86308432d81 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:23:55 -0500 Subject: [PATCH 11/20] Update docs --- Sources/CodeEditTextView/TextView/TextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index ddecd8ba3..8db416d49 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -240,7 +240,7 @@ public class TextView: NSView, NSTextContent { /// The selection manager for the text view. package(set) public var selectionManager: TextSelectionManager! - /// Manages text highlights within the text view + /// Managed emphasized text ranges in the text view public var emphasisManager: EmphasisManager? // MARK: - Private Properties From 50081c02cb016f4e3df8acfc1a1945cc3a834a1a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:25:26 -0500 Subject: [PATCH 12/20] Move `Emphasis` and `EmphasisStyle` to Files --- .../EmphasisManager/Emphasis.swift | 31 +++++++++++++ .../EmphasisManager/EmphasisManager.swift | 46 ------------------- .../EmphasisManager/EmphasisStyle.swift | 31 +++++++++++++ 3 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 Sources/CodeEditTextView/EmphasisManager/Emphasis.swift create mode 100644 Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift diff --git a/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift new file mode 100644 index 000000000..6f665acec --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift @@ -0,0 +1,31 @@ +// +// Emphasis.swift +// CodeEditTextView +// +// Created by Khan Winter on 3/31/25. +// + +import AppKit + +/// Represents a single emphasis with its properties +public struct Emphasis { + public let range: NSRange + public let style: EmphasisStyle + public let flash: Bool + public let inactive: Bool + public let select: Bool + + public init( + range: NSRange, + style: EmphasisStyle = .standard, + flash: Bool = false, + inactive: Bool = false, + select: Bool = false + ) { + self.range = range + self.style = style + self.flash = flash + self.inactive = inactive + self.select = select + } +} diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index 0c7b4d23f..51c69b651 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -7,52 +7,6 @@ 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 - } - } -} - -/// Represents a single emphasis with its properties -public struct Emphasis { - public let range: NSRange - public let style: EmphasisStyle - public let flash: Bool - public let inactive: Bool - public let select: Bool - - public init( - range: NSRange, - style: EmphasisStyle = .standard, - flash: Bool = false, - inactive: Bool = false, - select: Bool = false - ) { - self.range = range - self.style = style - self.flash = flash - self.inactive = inactive - self.select = select - } -} - /// Manages text emphases within a text view, supporting multiple styles and groups. public final class EmphasisManager { /// Internal representation of a emphasis layer with its associated text layer diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift new file mode 100644 index 000000000..443ffb794 --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift @@ -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 + } + } +} From 5bdf6011b5e8a3653bf56c21a01053cd42c01520 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:27:44 -0500 Subject: [PATCH 13/20] Remove Extra Closing Bracket --- .../CodeEditTextView/EmphasisManager/EmphasisManager.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index f69e0bba8..e7f487ced 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -287,8 +287,7 @@ public final class EmphasisManager { if let textLayer = textLayer, let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation { textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") - textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") - } + textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") } // Remove both layers after animation completes @@ -297,7 +296,7 @@ public final class EmphasisManager { textLayer?.removeFromSuperlayer() } } - + /// Handles selection of text ranges for emphases where select is true private func handleSelections(for emphases: [Emphasis]) { let selectableRanges = emphases.filter(\.select).map(\.range) From 365addbeacdfdd898f74f42c50c58bfed95f5318 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:28:38 -0500 Subject: [PATCH 14/20] Lint Error --- Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index e7f487ced..f14b945e3 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -296,7 +296,7 @@ public final class EmphasisManager { textLayer?.removeFromSuperlayer() } } - + /// Handles selection of text ranges for emphases where select is true private func handleSelections(for emphases: [Emphasis]) { let selectableRanges = emphases.filter(\.select).map(\.range) From 49b9a5e0e9266dd61816208bbfc6eaa3f0545f71 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:15:20 -0700 Subject: [PATCH 15/20] Comment on line drawing --- .../TextLine/LineFragmentView.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index a97d4e203..ff5625d23 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -42,6 +42,21 @@ final class LineFragmentView: NSView { } context.saveGState() + // Removes jagged edges + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + + // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than + // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness + // in low-contrast settings. + context.setAllowsFontSubpixelPositioning(true) + context.setShouldSubpixelPositionFonts(true) + + // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher + // quality bitmaps and performance. + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + ContextSetHiddenSmoothingStyle(context, 16) context.textMatrix = .init(scaleX: 1, y: -1) From 2dd0fadc72a6ef47a4311b3d1750a37466e80bbb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:25:20 -0700 Subject: [PATCH 16/20] Return Early From `updateSelectionViews` When Not First Responder --- .../TextSelectionManager/TextSelectionManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 03388a162..dde0a2c98 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -134,6 +134,7 @@ public class TextSelectionManager: NSObject { /// Update all selection cursors. Placing them in the correct position for each text selection and reseting the /// blink timer. func updateSelectionViews() { + guard textView?.isFirstResponder ?? false else { return } var didUpdate: Bool = false for textSelection in textSelections { From 6eb9f08f63f007059e5fff332cebf7ec426b10db Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:33:44 -0700 Subject: [PATCH 17/20] Docs, Naming --- .../EmphasisManager/Emphasis.swift | 22 ++++++++++++++++--- .../EmphasisManager/EmphasisManager.swift | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift index 6f665acec..aac05d528 100644 --- a/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift +++ b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift @@ -9,23 +9,39 @@ 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 - public let select: 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, - select: Bool = false + selectInDocument: Bool = false ) { self.range = range self.style = style self.flash = flash self.inactive = inactive - self.select = select + self.selectInDocument = selectInDocument } } diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index f14b945e3..4b008df38 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -299,7 +299,7 @@ public final class EmphasisManager { /// Handles selection of text ranges for emphases where select is true private func handleSelections(for emphases: [Emphasis]) { - let selectableRanges = emphases.filter(\.select).map(\.range) + let selectableRanges = emphases.filter(\.selectInDocument).map(\.range) guard let textView, !selectableRanges.isEmpty else { return } textView.selectionManager.setSelectedRanges(selectableRanges) From e16c9d19540ee07f3d5ba2218cc7d92dd56ed73f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 6 Apr 2025 08:43:07 -0600 Subject: [PATCH 18/20] Docs Co-authored-by: Tom Ludwig --- Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index 4b008df38..e149d0359 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -30,7 +30,7 @@ public final class EmphasisManager { /// Adds a single emphasis to the specified group. /// - Parameters: /// - emphasis: The emphasis to add - /// - id: The group identifier + /// - id: A group identifier public func addEmphasis(_ emphasis: Emphasis, for id: String) { addEmphases([emphasis], for: id) } From 1495df72fcb3c47797746f9ba7d6d2f109ba5e63 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 6 Apr 2025 08:45:53 -0600 Subject: [PATCH 19/20] Docs - Spelling --- Sources/CodeEditTextView/TextView/TextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 8db416d49..282e280a2 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -240,7 +240,7 @@ public class TextView: NSView, NSTextContent { /// The selection manager for the text view. package(set) public var selectionManager: TextSelectionManager! - /// Managed emphasized text ranges in the text view + /// Manages emphasized text ranges in the text view public var emphasisManager: EmphasisManager? // MARK: - Private Properties From 5136f8e4c6622a179eadd994aa3062429e3436dc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 6 Apr 2025 08:51:50 -0600 Subject: [PATCH 20/20] Add EmphasisManager Docs --- .../EmphasisManager/EmphasisManager.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index e149d0359..091829348 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -8,6 +8,16 @@ 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 {