From ac9cbf989f5cc3cbccf2a7fb5421846f67659b03 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:10:49 -0500 Subject: [PATCH 1/4] Fix EmphasisAPI Leaking Layers, Fix Emphasis Drawing --- .../EmphasisManager/Emphasis.swift | 2 +- .../EmphasisManager/EmphasisManager.swift | 126 +++++++++------ .../EmphasisManager/EmphasisStyle.swift | 9 ++ .../TextLayoutManager+Public.swift | 149 ++++++++---------- .../TextLayoutManger+ensureLayout.swift | 33 ++++ .../TextLine/LineFragment.swift | 14 ++ 6 files changed, 203 insertions(+), 130 deletions(-) create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift diff --git a/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift index aac05d52..0e7f4490 100644 --- a/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift +++ b/Sources/CodeEditTextView/EmphasisManager/Emphasis.swift @@ -8,7 +8,7 @@ import AppKit /// Represents a single emphasis with its properties -public struct Emphasis { +public struct Emphasis: Equatable { /// The range the emphasis applies it's style to, relative to the entire text document. public let range: NSRange diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index 09182934..db03c44e 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -20,10 +20,17 @@ import AppKit /// 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 { + private struct EmphasisLayer: Equatable { let emphasis: Emphasis let layer: CAShapeLayer let textLayer: CATextLayer? + + func removeLayers() { + layer.removeAllAnimations() + layer.removeFromSuperlayer() + textLayer?.removeAllAnimations() + textLayer?.removeFromSuperlayer() + } } private var emphasisGroups: [String: [EmphasisLayer]] = [:] @@ -37,6 +44,8 @@ public final class EmphasisManager { self.textView = textView } + // MARK: - Add, Update, Remove + /// Adds a single emphasis to the specified group. /// - Parameters: /// - emphasis: The emphasis to add @@ -56,24 +65,27 @@ public final class EmphasisManager { } let layers = emphases.map { createEmphasisLayer(for: $0) } - emphasisGroups[id] = layers - + emphasisGroups[id, default: []].append(contentsOf: layers) // Handle selections handleSelections(for: emphases) // Handle flash animations - for (index, emphasis) in emphases.enumerated() where emphasis.flash { - let layer = layers[index] + for flashingLayer in emphasisGroups[id, default: []].filter { $0.emphasis.flash } { 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.applyFadeOutAnimation(to: flashingLayer.layer, textLayer: flashingLayer.textLayer) { + // Remove the emphasis from the group if it still exists + guard let emphasisIdx = self.emphasisGroups[id, default: []].firstIndex( + where: { $0 == flashingLayer } + ) else { + return + } + + self.emphasisGroups[id, default: []][emphasisIdx].removeLayers() + self.emphasisGroups[id, default: []].remove(at: emphasisIdx) + + if self.emphasisGroups[id, default: []].isEmpty { self.emphasisGroups.removeValue(forKey: id) - } else { - self.emphasisGroups[id] = emphases } } } @@ -94,8 +106,7 @@ public final class EmphasisManager { /// - 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 existingEmphases = emphasisGroups[id, default: []].map { $0.emphasis } let newEmphases = transform(existingEmphases) replaceEmphases(newEmphases, for: id) } @@ -103,13 +114,12 @@ public final class EmphasisManager { /// 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]?.forEach { emphasis in + emphasis.removeLayers() } emphasisGroups[id] = nil + + textView?.layer?.layoutIfNeeded() } /// Removes all emphases for all groups. @@ -117,7 +127,7 @@ public final class EmphasisManager { emphasisGroups.keys.forEach { removeEmphases(for: $0) } emphasisGroups.removeAll() - // Restore original selection emphasising + // Restore original selection emphasizing if let originalColor = originalSelectionColor { textView?.selectionManager.selectionBackgroundColor = originalColor } @@ -128,38 +138,44 @@ public final class EmphasisManager { /// - Parameter id: The group identifier /// - Returns: Array of emphases in the group public func getEmphases(for id: String) -> [Emphasis] { - emphasisGroups[id]?.map { $0.emphasis } ?? [] + emphasisGroups[id, default: []].map(\.emphasis) } + // MARK: - Drawing Layers + /// 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 - } + for emphasis in emphasisGroups.flatMap(\.value) { + guard let shapePath = makeShapePath( + forStyle: emphasis.emphasis.style, + range: emphasis.emphasis.range + ) else { + continue + } + if #available(macOS 14.0, *) { + emphasis.layer.path = shapePath.cgPath + } else { + emphasis.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 = emphasis.layer.path { + let boundingBox = cgPath.boundingBox + emphasis.layer.bounds = boundingBox + emphasis.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 = emphasis.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 { + guard let shapePath = makeShapePath(forStyle: emphasis.style, range: emphasis.range) else { return EmphasisLayer(emphasis: emphasis, layer: CAShapeLayer(), textLayer: nil) } @@ -178,6 +194,25 @@ public final class EmphasisManager { return EmphasisLayer(emphasis: emphasis, layer: layer, textLayer: textLayer) } + private func makeShapePath(forStyle emphasisStyle: EmphasisStyle, range: NSRange) -> NSBezierPath? { + switch emphasisStyle { + case .standard, .outline: + return textView?.layoutManager.roundedPathForRange(range, cornerRadius: emphasisStyle.shapeRadius) + case .underline: + guard let layoutManager = textView?.layoutManager else { + return nil + } + let lineHeight = layoutManager.estimateLineHeight() + let lineBottomPadding = (lineHeight - (lineHeight / layoutManager.lineHeightMultiplier)) / 4 + var path = NSBezierPath() + for rect in layoutManager.rectsFor(range: range) { + path.move(to: NSPoint(x: rect.minX, y: rect.maxY - lineBottomPadding)) + path.line(to: NSPoint(x: rect.maxX, y: rect.maxY - lineBottomPadding)) + } + return path + } + } + private func createShapeLayer(shapePath: NSBezierPath, emphasis: Emphasis) -> CAShapeLayer { let layer = CAShapeLayer() @@ -274,6 +309,8 @@ public final class EmphasisManager { return .black } + // MARK: - Animations + private func applyPopAnimation(to layer: CALayer) { let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") scaleAnimation.values = [1.0, 1.25, 1.0] @@ -284,7 +321,7 @@ public final class EmphasisManager { layer.add(scaleAnimation, forKey: "popAnimation") } - private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?) { + private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?, completion: @escaping () -> Void) { let fadeAnimation = CABasicAnimation(keyPath: "opacity") fadeAnimation.fromValue = 1.0 fadeAnimation.toValue = 0.0 @@ -301,9 +338,10 @@ public final class EmphasisManager { } // Remove both layers after animation completes - DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { [weak layer, weak textLayer] in - layer?.removeFromSuperlayer() + DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { + layer.removeFromSuperlayer() textLayer?.removeFromSuperlayer() + completion() } } diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift index 443ffb79..495dbfd3 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift @@ -28,4 +28,13 @@ public enum EmphasisStyle: Equatable { return false } } + + var shapeRadius: CGFloat { + switch self { + case .standard: + 4 + case .underline, .outline: + 0 + } + } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 4a849742..21683310 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -153,12 +153,58 @@ extension TextLayoutManager { ) } - // swiftlint:disable function_body_length + /// Calculates all text bounding rects that intersect with a given range. + /// - Parameters: + /// - range: The range to calculate bounding rects for. + /// - line: The line to calculate rects for. + /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. + public func rectsFor(range: NSRange) -> [CGRect] { + lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } + } + + /// Calculates all text bounding rects that intersect with a given range, with a given line position. + /// - Parameters: + /// - range: The range to calculate bounding rects for. + /// - line: The line to calculate rects for. + /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. + private func rectsFor(range: NSRange, in line: borrowing TextLineStorage.TextLinePosition) -> [CGRect] { + guard let textStorage = (textStorage?.string as? NSString) else { return [] } + + // Don't make rects in between characters + let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: range.lowerBound) + ?? NSRange(location: range.lowerBound, length: 0) + let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: range.upperBound - 1) + ?? NSRange(location: range.upperBound - 1, length: 0) + + // Fragments are relative to the line + let relativeRange = NSRange( + start: realRangeStart.lowerBound - line.range.location, + end: realRangeEnd.upperBound - line.range.location + ) + + var rects: [CGRect] = [] + for fragmentPosition in line.data.lineFragments.linesInRange(relativeRange) { + guard let intersectingRange = fragmentPosition.range.intersection(relativeRange) else { continue } + let fragmentRect = fragmentPosition.data.rectFor(range: intersectingRange) + rects.append( + CGRect( + x: fragmentRect.minX + edgeInsets.left, + y: fragmentPosition.yPos + line.yPos, + width: fragmentRect.width, + height: fragmentRect.height + ) + ) + } + return rects + } + /// Creates a smooth bezier path for the specified range. /// If the range exceeds the available text, it uses the maximum available range. - /// - Parameter range: The range of text offsets to generate the path for. + /// - Parameters: + /// - range: The range of text offsets to generate the path for. + /// - cornerRadius: The radius of the edges when rounding. Defaults to four. /// - Returns: An `NSBezierPath` representing the visual shape for the text range, or `nil` if the range is invalid. - public func roundedPathForRange(_ range: NSRange) -> NSBezierPath? { + public func roundedPathForRange(_ range: NSRange, cornerRadius: CGFloat = 4) -> NSBezierPath? { // Ensure the range is within the bounds of the text storage let validRange = NSRange( location: range.lowerBound, @@ -170,63 +216,20 @@ extension TextLayoutManager { var rightSidePoints: [CGPoint] = [] // Points for Bottom-right → Top-right var leftSidePoints: [CGPoint] = [] // Points for Bottom-left → Top-left - var currentOffset = validRange.lowerBound - - // Process each line fragment within the range - while currentOffset < validRange.upperBound { - guard let linePosition = lineStorage.getLine(atOffset: currentOffset) else { return nil } - - if linePosition.data.lineFragments.isEmpty { - let newHeight = ensureLayoutFor(position: linePosition) - if linePosition.height != newHeight { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - } - - guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( - atOffset: currentOffset - linePosition.range.location - ) else { break } - - // Calculate the X positions for the range's boundaries within the fragment - let realRangeStart = (textStorage?.string as? NSString)? - .rangeOfComposedCharacterSequence(at: validRange.lowerBound) - ?? NSRange(location: validRange.lowerBound, length: 0) - - let realRangeEnd = (textStorage?.string as? NSString)? - .rangeOfComposedCharacterSequence(at: validRange.upperBound - 1) - ?? NSRange(location: validRange.upperBound - 1, length: 0) - - let minXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRangeStart.location - linePosition.range.location, - nil - ) + edgeInsets.left - - let maxXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRangeEnd.upperBound - linePosition.range.location, - nil - ) + edgeInsets.left - - // Ensure the fragment has a valid width - guard maxXPos > minXPos else { break } - - // Add the Y positions for the fragment - let topY = linePosition.yPos + fragmentPosition.yPos + fragmentPosition.data.scaledHeight - let bottomY = linePosition.yPos + fragmentPosition.yPos - - // Append points in the correct order - rightSidePoints.append(contentsOf: [ - CGPoint(x: maxXPos, y: bottomY), // Bottom-right - CGPoint(x: maxXPos, y: topY) // Top-right - ]) - leftSidePoints.insert(contentsOf: [ - CGPoint(x: minXPos, y: topY), // Top-left - CGPoint(x: minXPos, y: bottomY) // Bottom-left - ], at: 0) - - // Move to the next fragment - currentOffset = min(validRange.upperBound, linePosition.range.upperBound) + for fragmentRect in rectsFor(range: range) { + rightSidePoints.append( + contentsOf: [ + CGPoint(x: fragmentRect.maxX, y: fragmentRect.minY), // Bottom-right + CGPoint(x: fragmentRect.maxX, y: fragmentRect.maxY) // Top-right + ] + ) + leftSidePoints.insert( + contentsOf: [ + CGPoint(x: fragmentRect.minX, y: fragmentRect.maxY), // Top-left + CGPoint(x: fragmentRect.minX, y: fragmentRect.minY) // Bottom-left + ], + at: 0 + ) } // Combine the points in clockwise order @@ -234,12 +237,11 @@ extension TextLayoutManager { // Close the path if let firstPoint = points.first { - return NSBezierPath.smoothPath(points + [firstPoint], radius: 4) + return NSBezierPath.smoothPath(points + [firstPoint], radius: cornerRadius) } return nil } - // swiftlint:enable function_body_length /// Finds a suitable cursor rect for the end position. /// - Returns: A CGRect if it could be created. @@ -292,27 +294,4 @@ extension TextLayoutManager { delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) } } - - /// Forces layout calculation for all lines up to and including the given offset. - /// - Parameter offset: The offset to ensure layout until. - private func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { - guard let textStorage else { return 0 } - let displayData = TextLine.DisplayData( - maxWidth: maxLineLayoutWidth, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight() - ) - position.data.prepareForDisplay( - displayData: displayData, - range: position.range, - stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy - ) - var height: CGFloat = 0 - for fragmentPosition in position.data.lineFragments { - height += fragmentPosition.data.scaledHeight - } - return height - } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift new file mode 100644 index 00000000..813c29e6 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift @@ -0,0 +1,33 @@ +// +// TextLayoutManger+ensureLayout.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/7/25. +// + +import Foundation + +extension TextLayoutManager { + /// Forces layout calculation for all lines up to and including the given offset. + /// - Parameter offset: The offset to ensure layout until. + package func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { + guard let textStorage else { return 0 } + let displayData = TextLine.DisplayData( + maxWidth: maxLineLayoutWidth, + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimateLineHeight() + ) + position.data.prepareForDisplay( + displayData: displayData, + range: position.range, + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range), + breakStrategy: lineBreakStrategy + ) + var height: CGFloat = 0 + for fragmentPosition in position.data.lineFragments { + height += fragmentPosition.data.scaledHeight + } + return height + } +} diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index ebe6db74..3a62e2b9 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -39,4 +39,18 @@ public final class LineFragment: Identifiable, Equatable { public static func == (lhs: LineFragment, rhs: LineFragment) -> Bool { lhs.id == rhs.id } + + /// Calculates the drawing rect for a given range. + /// - Parameter range: The range to calculate the bounds for, relative to the line. + /// - Returns: A rect that contains the text contents in the given range. + func rectFor(range: NSRange) -> CGRect { + let minXPos = CTLineGetOffsetForStringIndex(ctLine, range.lowerBound, nil) + let maxXPos = CTLineGetOffsetForStringIndex(ctLine, range.upperBound, nil) + return CGRect( + x: minXPos, + y: 0, + width: maxXPos - minXPos, + height: scaledHeight + ) + } } From 261ece1553e95fd98b269f62089012040dc75895 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:38:44 -0500 Subject: [PATCH 2/4] Fix Warnings, Add Test Case --- .../EmphasisManager/EmphasisManager.swift | 4 +-- .../TextLayoutManager+Public.swift | 2 -- .../TextLayoutManager/TextLayoutManager.swift | 3 +- .../EmphasisManagerTests.swift | 36 +++++++++++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 Tests/CodeEditTextViewTests/EmphasisManagerTests.swift diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift index db03c44e..833996be 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift @@ -70,7 +70,7 @@ public final class EmphasisManager { handleSelections(for: emphases) // Handle flash animations - for flashingLayer in emphasisGroups[id, default: []].filter { $0.emphasis.flash } { + for flashingLayer in emphasisGroups[id, default: []].filter({ $0.emphasis.flash }) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self = self else { return } self.applyFadeOutAnimation(to: flashingLayer.layer, textLayer: flashingLayer.textLayer) { @@ -204,7 +204,7 @@ public final class EmphasisManager { } let lineHeight = layoutManager.estimateLineHeight() let lineBottomPadding = (lineHeight - (lineHeight / layoutManager.lineHeightMultiplier)) / 4 - var path = NSBezierPath() + let path = NSBezierPath() for rect in layoutManager.rectsFor(range: range) { path.move(to: NSPoint(x: rect.minX, y: rect.maxY - lineBottomPadding)) path.line(to: NSPoint(x: rect.maxX, y: rect.maxY - lineBottomPadding)) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 21683310..90cf4eff 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -172,9 +172,7 @@ extension TextLayoutManager { // Don't make rects in between characters let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: range.lowerBound) - ?? NSRange(location: range.lowerBound, length: 0) let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: range.upperBound - 1) - ?? NSRange(location: range.upperBound - 1, length: 0) // Fragments are relative to the line let relativeRange = NSRange( diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 1173b81d..2afe9a93 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -212,8 +212,7 @@ public class TextLayoutManager: NSObject { /// Lays out all visible lines func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length assertNotInLayout() - guard layoutView?.superview != nil, - let visibleRect = rect ?? delegate?.visibleRect, + guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { return diff --git a/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift new file mode 100644 index 00000000..c0b3aae2 --- /dev/null +++ b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift @@ -0,0 +1,36 @@ +import Testing +import Foundation +@testable import CodeEditTextView + +@Suite() +struct EmphasisManagerTests { + @Test() @MainActor + func testFlashEmphasisLayersNotLeaked() { + // Ensure layers are not leaked when switching from flash emphasis to any other emphasis type. + let textView = TextView(string: "Lorem Ipsum") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 100) + textView.layoutManager.layoutLines(in: CGRect(origin: .zero, size: CGSize(width: 1000, height: 100))) + textView.emphasisManager?.addEmphasis( + Emphasis(range: NSRange(location: 0, length: 5), style: .standard, flash: true), + for: "e" + ) + + // Text layer and emphasis layer + #expect(textView.layer?.sublayers?.count == 2) + #expect(textView.emphasisManager?.getEmphases(for: "e").count == 1) + + textView.emphasisManager?.replaceEmphases( + [Emphasis(range: NSRange(location: 0, length: 5), style: .underline(color: .red), flash: false)], + for: "e" + ) + + #expect(textView.layer?.sublayers?.count == 2) + #expect(textView.emphasisManager?.getEmphases(for: "e").count == 1) + + textView.emphasisManager?.removeAllEmphases() + + // No emphases + #expect(textView.layer?.sublayers?.count == nil) + #expect(textView.emphasisManager?.getEmphases(for: "e").count == 0) + } +} From 3f5aaaca4ff8fc5084aa11fed01ad78c0b233a6a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:44:44 -0500 Subject: [PATCH 3/4] Small Test Modification --- Tests/CodeEditTextViewTests/EmphasisManagerTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift index c0b3aae2..a921f105 100644 --- a/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift +++ b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift @@ -19,17 +19,17 @@ struct EmphasisManagerTests { #expect(textView.layer?.sublayers?.count == 2) #expect(textView.emphasisManager?.getEmphases(for: "e").count == 1) - textView.emphasisManager?.replaceEmphases( - [Emphasis(range: NSRange(location: 0, length: 5), style: .underline(color: .red), flash: false)], + textView.emphasisManager?.addEmphases( + [Emphasis(range: NSRange(location: 0, length: 5), style: .underline(color: .red), flash: true)], for: "e" ) - #expect(textView.layer?.sublayers?.count == 2) - #expect(textView.emphasisManager?.getEmphases(for: "e").count == 1) + #expect(textView.layer?.sublayers?.count == 4) + #expect(textView.emphasisManager?.getEmphases(for: "e").count == 2) textView.emphasisManager?.removeAllEmphases() - // No emphases + // No emphasis layers remain #expect(textView.layer?.sublayers?.count == nil) #expect(textView.emphasisManager?.getEmphases(for: "e").count == 0) } From 3508462bff3ff9d84e964cd47085b33ceb86abbc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:49:48 -0500 Subject: [PATCH 4/4] Add Correct Outline Radius --- Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift index 495dbfd3..53fe1f29 100644 --- a/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift +++ b/Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift @@ -33,8 +33,10 @@ public enum EmphasisStyle: Equatable { switch self { case .standard: 4 - case .underline, .outline: + case .underline: 0 + case .outline: + 2.5 } } }