diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 002e3807b..896c34777 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -11,23 +11,6 @@ import TextFormation import TextStory extension TextViewController { - - internal enum BracketPairs { - static let allValues: [(String, String)] = [ - ("{", "}"), - ("[", "]"), - ("(", ")"), - ("\"", "\""), - ("'", "'") - ] - - static let emphasisValues: [(String, String)] = [ - ("{", "}"), - ("[", "]"), - ("(", ")") - ] - } - // MARK: - Filter Configuration /// Initializes any filters for text editing. diff --git a/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift new file mode 100644 index 000000000..bce82896c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift @@ -0,0 +1,29 @@ +// +// BracketPairs.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/5/25. +// + +enum BracketPairs { + static let allValues: [(String, String)] = [ + ("{", "}"), + ("[", "]"), + ("(", ")"), + ("\"", "\""), + ("'", "'") + ] + + static let emphasisValues: [(String, String)] = [ + ("{", "}"), + ("[", "]"), + ("(", ")") + ] + + /// Checks if the given string is a matchable emphasis string. + /// - Parameter potentialMatch: The string to check for matches. + /// - Returns: True if a match was found with either start or end bracket pairs. + static func matches(_ potentialMatch: String) -> Bool { + allValues.contains(where: { $0.0 == potentialMatch || $0.1 == potentialMatch }) + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift index 09156caea..25208fdcd 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift @@ -22,6 +22,7 @@ extension NSBezierPath { public static let bottomLeft = Corners(rawValue: 1 << 1) public static let topRight = Corners(rawValue: 1 << 2) public static let bottomRight = Corners(rawValue: 1 << 3) + public static let all: Corners = Corners(rawValue: 0b1111) } // swiftlint:disable:next function_body_length diff --git a/Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift b/Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift new file mode 100644 index 000000000..639c9f3e2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift @@ -0,0 +1,23 @@ +// +// NSColor+LightDark.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/4/25. +// + +import AppKit + +extension NSColor { + convenience init(light: NSColor, dark: NSColor) { + self.init(name: nil) { appearance in + return switch appearance.name { + case .aqua: + light + case .darkAqua: + dark + default: + NSColor() + } + } + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift new file mode 100644 index 000000000..34be2a8e7 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift @@ -0,0 +1,19 @@ +// +// NSRect+Transform.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/4/25. +// + +import AppKit + +extension NSRect { + func transform(x xVal: CGFloat = 0, y yVal: CGFloat = 0, width: CGFloat = 0, height: CGFloat = 0) -> NSRect { + NSRect( + x: self.origin.x + xVal, + y: self.origin.y + yVal, + width: self.width + width, + height: self.height + height + ) + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 3a12022d0..92cabb8c9 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -19,7 +19,7 @@ actor LineFoldCalculator { private var valueStreamContinuation: AsyncStream.Continuation private var textChangedTask: Task? - + /// Create a new calculator object that listens to a given stream for text changes. /// - Parameters: /// - foldProvider: The object to use to calculate fold regions. @@ -39,7 +39,7 @@ actor LineFoldCalculator { deinit { textChangedTask?.cancel() } - + /// Sets up an attached task to listen to values on a stream of text changes. /// - Parameter textChangedStream: A stream of text changes. private func listenToTextChanges(textChangedStream: AsyncStream<(NSRange, Int)>) { @@ -105,7 +105,7 @@ actor LineFoldCalculator { await yieldNewStorage(newFolds: foldCache, controller: controller, documentRange: documentRange) } - + /// Yield a new storage value on the value stream using a new set of folds. /// - Parameters: /// - newFolds: The new folds to yield with the storage value. diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index 08137c418..53b00cc9a 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -19,6 +19,9 @@ import Combine /// - Loop through the list, creating nested folds as indents go up and down. /// class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { + static let emphasisId = "lineFolding" + + /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` /// and ``FoldRange/subFolds``. @Published var foldCache: LineFoldStorage = LineFoldStorage(documentLength: 0) @@ -92,4 +95,35 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { } return deepestFold } + + func emphasizeBracketsForFold(_ fold: FoldRange) { + clearEmphasis() + + // Find the text object, make sure there's available characters around the fold. + guard let text = controller?.textView.textStorage.string as? NSString, + fold.range.lowerBound > 0 && fold.range.upperBound < text.length - 1 else { + return + } + + let firstRange = NSRange(location: fold.range.lowerBound - 1, length: 1) + let secondRange = NSRange(location: fold.range.upperBound, length: 1) + + // Check if these are emphasizable bracket pairs. + guard BracketPairs.matches(text.substring(from: firstRange) ?? "") + && BracketPairs.matches(text.substring(from: secondRange) ?? "") else { + return + } + + controller?.textView.emphasisManager?.addEmphases( + [ + Emphasis(range: firstRange, style: .standard, flash: false, inactive: false, selectInDocument: false), + Emphasis(range: secondRange, style: .standard, flash: false, inactive: false, selectInDocument: false), + ], + for: Self.emphasisId + ) + } + + func clearEmphasis() { + controller?.textView.emphasisManager?.removeEmphases(for: Self.emphasisId) + } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift index 40ecd262c..a796376aa 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -10,22 +10,40 @@ import CodeEditTextView class LineFoldPlaceholder: TextAttachment { let fold: FoldRange + let charWidth: CGFloat + var isSelected: Bool = false - init(fold: FoldRange) { + init(fold: FoldRange, charWidth: CGFloat) { self.fold = fold + self.charWidth = charWidth } - var width: CGFloat { 17 } + var width: CGFloat { + charWidth * 5 + } func draw(in context: CGContext, rect: NSRect) { context.saveGState() let centerY = rect.midY - 1.5 + if isSelected { + context.setFillColor(NSColor.controlAccentColor.cgColor) + context.addPath( + NSBezierPath( + rect: rect.transform(x: 2.0, y: 3.0, width: -4.0, height: -6.0 ), + roundedCorners: .all, + cornerRadius: 2 + ).cgPathFallback + ) + context.fillPath() + } + context.setFillColor(NSColor.secondaryLabelColor.cgColor) - context.addEllipse(in: CGRect(x: rect.minX + 2, y: centerY, width: 3, height: 3)) - context.addEllipse(in: CGRect(x: rect.minX + 7, y: centerY, width: 3, height: 3)) - context.addEllipse(in: CGRect(x: rect.minX + 12, y: centerY, width: 3, height: 3)) + let size = charWidth / 2 + context.addEllipse(in: CGRect(x: rect.minX + charWidth * 1.25, y: centerY, width: size, height: size)) + context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 2.25), y: centerY, width: size, height: size)) + context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 3.25), y: centerY, width: size, height: size)) context.fillPath() context.restoreGState() diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index 1a0650291..3680d832f 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -15,23 +15,26 @@ extension FoldingRibbonView { let endLine: TextLineStorage.TextLinePosition } + // MARK: - Draw + override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext, - let layoutManager = model?.controller?.textView.layoutManager else { + let layoutManager = model?.controller?.textView.layoutManager, + // Find the visible lines in the rect AppKit is asking us to draw. + let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), + let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { return } context.saveGState() context.clip(to: dirtyRect) - // Find the visible lines in the rect AppKit is asking us to draw. - guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), - let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { - return - } - let textRange = rangeStart.range.location.. 1. In this case, we still need to draw those + /// layers of color to create the illusion that those folds are continuous under the nested folds. To achieve this, + /// we create 'fake' folds that span more than the queried text range. When returned for drawing, the drawing + /// methods will draw those extra folds normally. + /// + /// - Parameters: + /// - textRange: The range of characters in text to create drawing fold info for. + /// - layoutManager: A layout manager to query for line layout information. + /// - Returns: A list of folds to draw for the given text range. private func getDrawingFolds( forTextRange textRange: Range, layoutManager: TextLayoutManager @@ -85,14 +102,15 @@ extension FoldingRibbonView { } } + // MARK: - Draw Fold Marker + /// Draw a single fold marker for a fold. /// /// Ensure the correct fill color is set on the drawing context before calling. /// /// - Parameters: /// - foldInfo: The fold to draw. - /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is - /// being hovered. + /// - foldCaps: /// - context: The drawing context to use. /// - layoutManager: A layout manager used to retrieve position information for lines. private func drawFoldMarker( @@ -103,34 +121,43 @@ extension FoldingRibbonView { ) { let minYPosition = foldInfo.startLine.yPos let maxYPosition = foldInfo.endLine.yPos + foldInfo.endLine.height + let foldRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) if foldInfo.fold.isCollapsed { - drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context) - } else if let hoveringFold, hoveringFold.isHoveringEqual(foldInfo.fold) { - drawHoveredFold( + drawCollapsedFold( + foldInfo: foldInfo, minYPosition: minYPosition, maxYPosition: maxYPosition, in: context ) + } else if hoveringFold.fold?.isHoveringEqual(foldInfo.fold) == true { + drawHoveredFold( + foldInfo: foldInfo, + foldCaps: foldCaps, + foldRect: foldRect, + in: context + ) } else { drawNestedFold( foldInfo: foldInfo, foldCaps: foldCaps, - minYPosition: minYPosition, - maxYPosition: maxYPosition, + foldRect: foldCaps.adjustFoldRect(using: foldInfo, rect: foldRect), in: context ) } } + // MARK: - Collapsed Fold + private func drawCollapsedFold( + foldInfo: DrawingFoldInfo, minYPosition: CGFloat, maxYPosition: CGFloat, in context: CGContext ) { context.saveGState() - let fillRect = CGRect(x: 0, y: minYPosition, width: Self.width, height: maxYPosition - minYPosition) + let fillRect = CGRect(x: 0, y: minYPosition + 1.0, width: Self.width, height: maxYPosition - minYPosition - 2.0) let height = 5.0 let minX = 2.0 @@ -144,12 +171,18 @@ extension FoldingRibbonView { chevron.addLine(to: CGPoint(x: maxX, y: centerY)) chevron.addLine(to: CGPoint(x: minX, y: maxY)) - context.setStrokeColor(NSColor.secondaryLabelColor.cgColor) + if let hoveringFoldMask = hoveringFold.foldMask, + hoveringFoldMask.intersects(CGPath(rect: fillRect, transform: .none)) { + context.addPath(hoveringFoldMask) + context.clip() + } + + context.setStrokeColor(foldedIndicatorChevronColor) context.setLineCap(.round) context.setLineJoin(.round) context.setLineWidth(1.3) - context.setFillColor(NSColor.tertiaryLabelColor.cgColor) + context.setFillColor(foldedIndicatorColor) context.fill(fillRect) context.addPath(chevron) context.strokePath() @@ -157,23 +190,38 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Hovered Fold + private func drawHoveredFold( - minYPosition: CGFloat, - maxYPosition: CGFloat, + foldInfo: DrawingFoldInfo, + foldCaps: FoldCapInfo, + foldRect: NSRect, in context: CGContext ) { context.saveGState() - let plainRect = NSRect(x: -2, y: minYPosition, width: 11.0, height: maxYPosition - minYPosition) - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 11.0 / 2, yRadius: 11.0 / 2) + let plainRect = foldRect.transform(x: -2.0, y: -1.0, width: 4.0, height: 2.0) + let roundedRect = NSBezierPath( + roundedRect: plainRect, + xRadius: plainRect.width / 2, + yRadius: plainRect.width / 2 + ) - context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor) - context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? hoverBorderColor) + context.setFillColor(hoverFillColor.copy(alpha: hoveringFold.progress) ?? hoverFillColor) + context.setStrokeColor(hoverBorderColor.copy(alpha: hoveringFold.progress) ?? hoverBorderColor) context.addPath(roundedRect.cgPathFallback) context.drawPath(using: .fillStroke) - // Add the little arrows - drawChevron(in: context, yPosition: minYPosition + 8, pointingUp: false) - drawChevron(in: context, yPosition: maxYPosition - 8, pointingUp: true) + // Add the little arrows if we're not hovering right on a collapsed guy + if foldCaps.hoveredFoldShouldDrawTopChevron(foldInfo) { + drawChevron(in: context, yPosition: plainRect.minY + 8, pointingUp: false) + } + if foldCaps.hoveredFoldShouldDrawBottomChevron(foldInfo) { + drawChevron(in: context, yPosition: plainRect.maxY - 8, pointingUp: true) + } + + let plainMaskRect = foldRect.transform(y: 1.0, height: -2.0) + let roundedMaskRect = NSBezierPath(roundedRect: plainMaskRect, xRadius: Self.width / 2, yRadius: Self.width / 2) + hoveringFold.foldMask = roundedMaskRect.cgPathFallback context.restoreGState() } @@ -187,9 +235,13 @@ extension FoldingRibbonView { let minX = center - (chevronSize.width / 2) let maxX = center + (chevronSize.width / 2) - let startY = pointingUp ? yPosition + chevronSize.height : yPosition - chevronSize.height + let startY = if pointingUp { + yPosition + chevronSize.height + } else { + yPosition - chevronSize.height + } - context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor) + context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoveringFold.progress).cgColor) context.setLineCap(.round) context.setLineJoin(.round) context.setLineWidth(1.3) @@ -203,24 +255,20 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Nested Fold + private func drawNestedFold( foldInfo: DrawingFoldInfo, foldCaps: FoldCapInfo, - minYPosition: CGFloat, - maxYPosition: CGFloat, + foldRect: NSRect, in context: CGContext ) { context.saveGState() - let plainRect = foldCaps.adjustFoldRect( - using: foldInfo, - rect: NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) - ) - let radius = plainRect.width / 2.0 let roundedRect = NSBezierPath( - roundingRect: plainRect, + roundingRect: foldRect, capTop: foldCaps.foldNeedsTopCap(foldInfo), capBottom: foldCaps.foldNeedsBottomCap(foldInfo), - cornerRadius: radius + cornerRadius: foldRect.width / 2.0 ) context.setFillColor(markerColor) @@ -232,8 +280,8 @@ extension FoldingRibbonView { drawOutline( foldInfo: foldInfo, foldCaps: foldCaps, + foldRect: foldRect, originalPath: roundedRect.cgPathFallback, - yPosition: minYPosition...maxYPosition, in: context ) } @@ -241,6 +289,8 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Nested Outline + /// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator. /// /// This function does not change fill colors for the given context. @@ -253,42 +303,26 @@ extension FoldingRibbonView { private func drawOutline( foldInfo: DrawingFoldInfo, foldCaps: FoldCapInfo, + foldRect: NSRect, originalPath: CGPath, - yPosition: ClosedRange, in context: CGContext ) { context.saveGState() - let plainRect = foldCaps.adjustFoldRect( - using: foldInfo, - rect: NSRect( - x: -0.5, - y: yPosition.lowerBound, - width: frame.width + 1.0, - height: yPosition.upperBound - yPosition.lowerBound - ) - ) - let radius = plainRect.width / 2.0 + let plainRect = foldRect.transform(x: -1.0, y: -1.0, width: 2.0, height: 2.0) let roundedRect = NSBezierPath( roundingRect: plainRect, capTop: foldCaps.foldNeedsTopCap(foldInfo), capBottom: foldCaps.foldNeedsBottomCap(foldInfo), - cornerRadius: radius + cornerRadius: plainRect.width / 2.0 ) - roundedRect.transform(using: .init(translationByX: -0.5, byY: 0.0)) + roundedRect.transform(using: .init(translationByX: -1.0, byY: 0.0)) let combined = CGMutablePath() combined.addPath(roundedRect.cgPathFallback) combined.addPath(originalPath) - context.clip( - to: CGRect( - x: 0, - y: yPosition.lowerBound, - width: 7, - height: yPosition.upperBound - yPosition.lowerBound - ) - ) + context.clip(to: foldRect.transform(y: -1.0, height: 2.0)) context.addPath(combined) context.setFillColor(markerBorderColor) context.drawPath(using: .eoFill) diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift index 1c766027a..3a77e86c5 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift @@ -8,21 +8,51 @@ import AppKit extension FoldingRibbonView { + /// A helper type that determines if a fold should be drawn with a cap on the top or bottom if + /// there's an adjacent fold on the same text line. It also provides a helper method to adjust fold rects using + /// the cap information. struct FoldCapInfo { - let startIndices: Set - let endIndices: Set + private let startIndices: Set + private let endIndices: Set + private let collapsedStartIndices: Set + private let collapsedEndIndices: Set init(_ folds: [DrawingFoldInfo]) { - self.startIndices = folds.reduce(into: Set(), { $0.insert($1.startLine.index) }) - self.endIndices = folds.reduce(into: Set(), { $0.insert($1.endLine.index) }) + var startIndices = Set() + var endIndices = Set() + var collapsedStartIndices = Set() + var collapsedEndIndices = Set() + + for fold in folds { + if fold.fold.isCollapsed { + collapsedStartIndices.insert(fold.startLine.index) + collapsedEndIndices.insert(fold.endLine.index) + } else { + startIndices.insert(fold.startLine.index) + endIndices.insert(fold.endLine.index) + } + } + + self.startIndices = startIndices + self.endIndices = endIndices + self.collapsedStartIndices = collapsedStartIndices + self.collapsedEndIndices = collapsedEndIndices } func foldNeedsTopCap(_ fold: DrawingFoldInfo) -> Bool { - endIndices.contains(fold.startLine.index) + endIndices.contains(fold.startLine.index) || collapsedEndIndices.contains(fold.startLine.index) } func foldNeedsBottomCap(_ fold: DrawingFoldInfo) -> Bool { - startIndices.contains(fold.endLine.index) + startIndices.contains(fold.endLine.index) || collapsedStartIndices.contains(fold.endLine.index) + } + + func hoveredFoldShouldDrawTopChevron(_ fold: DrawingFoldInfo) -> Bool { + !collapsedEndIndices.contains(fold.startLine.index) + } + + func hoveredFoldShouldDrawBottomChevron(_ fold: DrawingFoldInfo) -> Bool { + !collapsedStartIndices.contains(fold.endLine.index) } func adjustFoldRect( @@ -31,14 +61,20 @@ extension FoldingRibbonView { ) -> NSRect { let capTop = foldNeedsTopCap(fold) let capBottom = foldNeedsBottomCap(fold) - let yDelta = capTop ? fold.startLine.height / 2.0 : 0.0 - let heightDelta: CGFloat = if capTop && capBottom { - -fold.startLine.height - } else if capTop || capBottom { - -(fold.startLine.height / 2.0) + let yDelta: CGFloat = if capTop && !collapsedEndIndices.contains(fold.startLine.index) { + fold.startLine.height / 2.0 } else { 0.0 } + + var heightDelta: CGFloat = 0.0 + if capTop && !collapsedEndIndices.contains(fold.startLine.index) { + heightDelta -= fold.startLine.height / 2.0 + } + if capBottom && !collapsedStartIndices.contains(fold.endLine.index) { + heightDelta -= fold.endLine.height / 2.0 + } + return NSRect( x: rect.origin.x, y: rect.origin.y + yDelta, diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index e83790d68..54e86f4c9 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -8,12 +8,23 @@ import Foundation import AppKit import CodeEditTextView -import Combine /// Displays the code folding ribbon in the ``GutterView``. /// /// This view draws its contents class FoldingRibbonView: NSView { + struct HoverAnimationDetails: Equatable { + var fold: FoldRange? + var foldMask: CGPath? + var timer: Timer? + var progress: CGFloat = 0.0 + + static let empty = HoverAnimationDetails() + + public static func == (_ lhs: HoverAnimationDetails, _ rhs: HoverAnimationDetails) -> Bool { + lhs.fold == rhs.fold && lhs.foldMask == rhs.foldMask && lhs.progress == rhs.progress + } + } #warning("Replace before release") private static let demoFoldProvider = IndentationLineFoldProvider() @@ -22,63 +33,47 @@ class FoldingRibbonView: NSView { var model: LineFoldingModel? - // Disabling this lint rule because this initial value is required for @Invalidating @Invalidating(.display) - var hoveringFold: FoldRange? = nil // swiftlint:disable:this redundant_optional_initialization - var hoverAnimationTimer: Timer? - @Invalidating(.display) - var hoverAnimationProgress: CGFloat = 0.0 + var hoveringFold: HoverAnimationDetails = .empty @Invalidating(.display) var backgroundColor: NSColor = NSColor.controlBackgroundColor @Invalidating(.display) - var markerColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 0.0, alpha: 0.1) - case .darkAqua: - NSColor(deviceWhite: 1.0, alpha: 0.2) - default: - NSColor() - } - }.cgColor + var markerColor = NSColor( + light: NSColor(deviceWhite: 0.0, alpha: 0.1), + dark: NSColor(deviceWhite: 1.0, alpha: 0.2) + ).cgColor @Invalidating(.display) - var markerBorderColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 1.0, alpha: 0.4) - case .darkAqua: - NSColor(deviceWhite: 0.0, alpha: 0.4) - default: - NSColor() - } - }.cgColor + var markerBorderColor = NSColor( + light: NSColor(deviceWhite: 1.0, alpha: 0.4), + dark: NSColor(deviceWhite: 0.0, alpha: 0.4) + ).cgColor @Invalidating(.display) - var hoverFillColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 1.0, alpha: 1.0) - case .darkAqua: - NSColor(deviceWhite: 0.17, alpha: 1.0) - default: - NSColor() - } - }.cgColor + var hoverFillColor = NSColor( + light: NSColor(deviceWhite: 1.0, alpha: 1.0), + dark: NSColor(deviceWhite: 0.17, alpha: 1.0) + ).cgColor @Invalidating(.display) - var hoverBorderColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 0.8, alpha: 1.0) - case .darkAqua: - NSColor(deviceWhite: 0.4, alpha: 1.0) - default: - NSColor() - } - }.cgColor + var hoverBorderColor = NSColor( + light: NSColor(deviceWhite: 0.8, alpha: 1.0), + dark: NSColor(deviceWhite: 0.4, alpha: 1.0) + ).cgColor + + @Invalidating(.display) + var foldedIndicatorColor = NSColor( + light: NSColor(deviceWhite: 0.0, alpha: 0.3), + dark: NSColor(deviceWhite: 1.0, alpha: 0.6) + ).cgColor + + @Invalidating(.display) + var foldedIndicatorChevronColor = NSColor( + light: NSColor(deviceWhite: 1.0, alpha: 1.0), + dark: NSColor(deviceWhite: 0.0, alpha: 1.0) + ).cgColor override public var isFlipped: Bool { true @@ -141,13 +136,14 @@ class FoldingRibbonView: NSView { layoutManager.attachments.remove(atOffset: attachment.range.location) attachments.removeAll(where: { $0 === attachment.attachment }) } else { - let placeholder = LineFoldPlaceholder(fold: fold) + let placeholder = LineFoldPlaceholder(fold: fold, charWidth: model?.controller?.fontCharWidth ?? 1.0) layoutManager.attachments.add(placeholder, for: NSRange(fold.range)) attachments.append(placeholder) } model?.foldCache.toggleCollapse(forFold: fold) model?.controller?.textView.needsLayout = true + mouseMoved(with: event) } private func findAttachmentFor(fold: FoldRange, firstLineRange: NSRange) -> AnyTextAttachment? { @@ -165,44 +161,60 @@ class FoldingRibbonView: NSView { let pointInView = convert(event.locationInWindow, from: nil) guard let lineNumber = model?.controller?.textView.layoutManager.textLineForPosition(pointInView.y)?.index, - let fold = model?.getCachedFoldAt(lineNumber: lineNumber) else { - hoverAnimationProgress = 0.0 - hoveringFold = nil + let fold = model?.getCachedFoldAt(lineNumber: lineNumber), + !fold.isCollapsed else { + clearHoveredFold() return } - guard fold.range != hoveringFold?.range else { + guard fold.range != hoveringFold.fold?.range else { return } - hoverAnimationTimer?.invalidate() + + setHoveredFold(fold: fold) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + clearHoveredFold() + } + + /// Clears the current hovered fold. Does not animate. + func clearHoveredFold() { + hoveringFold = .empty + model?.clearEmphasis() + } + + /// Set the current hovered fold. This method determines when an animation is required and will facilitate it. + /// - Parameter fold: The fold to set as the current hovered fold. + func setHoveredFold(fold: FoldRange) { + defer { + model?.emphasizeBracketsForFold(fold) + } + + hoveringFold.timer?.invalidate() // We only animate the first hovered fold. If the user moves the mouse vertically into other folds we just // show it immediately. - if hoveringFold == nil { - hoverAnimationProgress = 0.0 - hoveringFold = fold - + if hoveringFold.fold == nil { let duration: TimeInterval = 0.2 let startTime = CACurrentMediaTime() - hoverAnimationTimer = Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [weak self] timer in - guard let self = self else { return } - let now = CACurrentMediaTime() - let time = CGFloat((now - startTime) / duration) - self.hoverAnimationProgress = min(1.0, time) - if self.hoverAnimationProgress >= 1.0 { - timer.invalidate() + + hoveringFold = HoverAnimationDetails( + fold: fold, + timer: Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [weak self] timer in + guard let self = self else { return } + let now = CACurrentMediaTime() + let time = CGFloat((now - startTime) / duration) + self.hoveringFold.progress = min(1.0, time) + if self.hoveringFold.progress >= 1.0 { + timer.invalidate() + } } - } + ) return } // Don't animate these - hoverAnimationProgress = 1.0 - hoveringFold = fold - } - - override func mouseExited(with event: NSEvent) { - super.mouseExited(with: event) - hoverAnimationProgress = 0.0 - hoveringFold = nil + hoveringFold = HoverAnimationDetails(fold: fold, progress: 1.0) } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 164cf1ad2..d41c0e478 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift @@ -110,7 +110,10 @@ extension RangeStore { newLength = editedRange.length } - storageUpdated(replacedCharactersIn: storageRange, withCount: newLength) + storageUpdated( + replacedCharactersIn: storageRange.clamped(to: 0..<_guts.count(in: OffsetMetric())), + withCount: newLength + ) } /// Handles keeping the internal storage in sync with the document.