diff --git a/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift new file mode 100644 index 000000000..aac05d528 --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift @@ -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 + } +} diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift new file mode 100644 index 000000000..091829348 --- /dev/null +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -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 + } +} 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 + } + } +} diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift deleted file mode 100644 index 0ed8a5716..000000000 --- a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift +++ /dev/null @@ -1,184 +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 = NSColor(hex: 0xFFFB00, alpha: 1) - private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) - - weak var textView: TextView? - - init(textView: TextView) { - self.textView = textView - } - - // MARK: - Structs - public struct EmphasizedRange { - public var range: NSRange - var layer: CAShapeLayer - } - - // MARK: - Public Methods - - /// Emphasises multiple ranges, with one optionally marked as active (highlighted usually in yellow). - /// - /// - Parameters: - /// - ranges: An array of ranges to highlight. - /// - activeIndex: The index of the range to highlight in yellow. Defaults to `nil`. - /// - clearPrevious: Removes previous emphasised ranges. Defaults to `true`. - public func emphasizeRanges(ranges: [NSRange], activeIndex: Int? = nil, clearPrevious: Bool = true) { - if clearPrevious { - removeEmphasizeLayers() // Clear all existing highlights - } - - ranges.enumerated().forEach { index, range in - let isActive = (index == activeIndex) - emphasizeRange(range: range, active: isActive) - - if isActive { - emphasizedRangeIndex = activeIndex - } - } - } - - /// Emphasises a single range. - /// - Parameters: - /// - range: The text range to highlight. - /// - active: Whether the range should be highlighted as active (usually in yellow). Defaults to `false`. - public func emphasizeRange(range: NSRange, active: Bool = false) { - guard let shapePath = textView?.layoutManager?.roundedPathForRange(range) else { return } - - let layer = createEmphasizeLayer(shapePath: shapePath, active: active) - textView?.layer?.insertSublayer(layer, at: 1) - - emphasizedRanges.append(EmphasizedRange(range: range, layer: layer)) - } - - /// Removes the highlight for a specific range. - /// - Parameter range: The range to remove. - public func removeHighlightForRange(_ range: NSRange) { - guard let index = emphasizedRanges.firstIndex(where: { $0.range == range }) else { return } - - let removedLayer = emphasizedRanges[index].layer - removedLayer.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 - } else if currentIndex > index { - emphasizedRangeIndex = currentIndex - 1 // Shift if the removed index was before the active index - } - } - } - - /// Highlights the previous emphasised range (usually in yellow). - /// - /// - Returns: An optional `NSRange` representing the newly active emphasized range. - /// Returns `nil` if there are no prior ranges to highlight. - @discardableResult - public func highlightPrevious() -> NSRange? { - return shiftActiveHighlight(amount: -1) - } - - /// Highlights the next emphasised range (usually in yellow). - /// - /// - Returns: An optional `NSRange` representing the newly active emphasized range. - /// Returns `nil` if there are no subsequent ranges to highlight. - @discardableResult - public func highlightNext() -> NSRange? { - return shiftActiveHighlight(amount: 1) - } - - /// Removes all emphasised ranges. - public func removeEmphasizeLayers() { - emphasizedRanges.forEach { $0.layer.removeFromSuperlayer() } - emphasizedRanges.removeAll() - emphasizedRangeIndex = nil - } - - // MARK: - Private Methods - - private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer { - let layer = CAShapeLayer() - layer.cornerRadius = 3.0 - layer.fillColor = (active ? activeColor : inactiveColor).cgColor - layer.shadowColor = .black - layer.shadowOpacity = active ? 0.3 : 0.0 - layer.shadowOffset = CGSize(width: 0, height: 1) - layer.shadowRadius = 3.0 - layer.opacity = 1.0 - - if #available(macOS 14.0, *) { - layer.path = shapePath.cgPath - } else { - layer.path = shapePath.cgPathFallback - } - - // 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 - if let currentIndex = emphasizedRangeIndex { - let previousLayer = emphasizedRanges[currentIndex].layer - previousLayer.fillColor = inactiveColor.cgColor - previousLayer.shadowOpacity = 0.0 - } - - // Set the new active layer - let newLayer = emphasizedRanges[currentIndex].layer - newLayer.fillColor = activeColor.cgColor - newLayer.shadowOpacity = 0.3 - - 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") - } -} 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 } } 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/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 043c1829e..ff5625d23 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -42,12 +42,18 @@ final class LineFragmentView: NSView { } context.saveGState() + // Removes jagged edges context.setAllowsAntialiasing(true) context.setShouldAntialias(true) - context.setAllowsFontSmoothing(false) - context.setShouldSmoothFonts(false) + + // 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) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index b6311a9ab..dde0a2c98 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 @@ -137,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 { diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 038dd349a..6f9a0067b 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) } + emphasisManager?.updateLayerBackgrounds() } override open var isFlipped: Bool { diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 84a0a46d6..282e280a2 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 emphasized text ranges in 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