diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3f475425b..243527a2a 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", - "version" : "0.10.1" + "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", + "version" : "0.11.1" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" } }, { diff --git a/Package.resolved b/Package.resolved index 1d320e4a6..7296d78dd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", - "version" : "0.10.1" + "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", + "version" : "0.11.1" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" } }, { diff --git a/Package.swift b/Package.swift index 751fc8829..69556c288 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.0" + from: "0.11.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift new file mode 100644 index 000000000..d85a25916 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift @@ -0,0 +1,205 @@ +// +// FoldingRibbonView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +extension FoldingRibbonView { + /// The context in which the fold is being drawn, including the depth and fold range. + struct FoldMarkerDrawingContext { + let range: ClosedRange + let depth: UInt + + /// Increment the depth + func incrementDepth() -> FoldMarkerDrawingContext { + FoldMarkerDrawingContext( + range: range, + depth: depth + 1 + ) + } + } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext, + let layoutManager = model.textView?.layoutManager 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 lineRange = rangeStart.index...rangeEnd.index + + context.setFillColor(markerColor) + let folds = model.getFolds(in: lineRange) + for fold in folds { + drawFoldMarker( + fold, + markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0), + in: context, + using: layoutManager + ) + } + + context.restoreGState() + } + + /// Draw a single fold marker for a fold. + /// + /// Ensure the correct fill color is set on the drawing context before calling. + /// + /// - Parameters: + /// - fold: The fold to draw. + /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is + /// being hovered. + /// - context: The drawing context to use. + /// - layoutManager: A layout manager used to retrieve position information for lines. + private func drawFoldMarker( + _ fold: FoldRange, + markerContext: FoldMarkerDrawingContext, + in context: CGContext, + using layoutManager: TextLayoutManager + ) { + guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos, + let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else { + return + } + + let maxYPosition = maxPosition.yPos + maxPosition.height + + if let hoveringFold, + hoveringFold.depth == markerContext.depth, + fold.lineRange == hoveringFold.range { + drawHoveredFold( + markerContext: markerContext, + minYPosition: minYPosition, + maxYPosition: maxYPosition, + in: context + ) + } else { + drawNestedFold( + markerContext: markerContext, + minYPosition: minYPosition, + maxYPosition: maxYPosition, + in: context + ) + } + + // Draw subfolds + for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { + drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) + } + } + + private func drawHoveredFold( + markerContext: FoldMarkerDrawingContext, + minYPosition: CGFloat, + maxYPosition: CGFloat, + 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) + + context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor) + context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? 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) + + context.restoreGState() + } + + private func drawChevron(in context: CGContext, yPosition: CGFloat, pointingUp: Bool) { + context.saveGState() + let path = CGMutablePath() + let chevronSize = CGSize(width: 4.0, height: 2.5) + + let center = (Self.width / 2) + let minX = center - (chevronSize.width / 2) + let maxX = center + (chevronSize.width / 2) + + let startY = pointingUp ? yPosition + chevronSize.height : yPosition - chevronSize.height + + context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setLineWidth(1.3) + + path.move(to: CGPoint(x: minX, y: startY)) + path.addLine(to: CGPoint(x: center, y: yPosition)) + path.addLine(to: CGPoint(x: maxX, y: startY)) + + context.addPath(path) + context.strokePath() + context.restoreGState() + } + + private func drawNestedFold( + markerContext: FoldMarkerDrawingContext, + minYPosition: CGFloat, + maxYPosition: CGFloat, + in context: CGContext + ) { + let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) + // TODO: Draw a single horizontal line when folds are adjacent + let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5) + + context.addPath(roundedRect.cgPathFallback) + context.drawPath(using: .fill) + + // Add small white line if we're overlapping with other markers + if markerContext.depth != 0 { + drawOutline( + minYPosition: minYPosition, + maxYPosition: maxYPosition, + originalPath: roundedRect, + in: context + ) + } + } + + /// 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. + /// + /// - Parameters: + /// - minYPosition: The minimum y position of the rectangle to outline. + /// - maxYPosition: The maximum y position of the rectangle to outline. + /// - originalPath: The original bezier path for the rounded rectangle. + /// - context: The context to draw in. + private func drawOutline( + minYPosition: CGFloat, + maxYPosition: CGFloat, + originalPath: NSBezierPath, + in context: CGContext + ) { + context.saveGState() + + let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition) + let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4) + + let combined = CGMutablePath() + combined.addPath(roundedRect.cgPathFallback) + combined.addPath(originalPath.cgPathFallback) + + context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) + context.addPath(combined) + context.setFillColor(markerBorderColor) + context.drawPath(using: .eoFill) + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index d7d8543bf..c06c9ac03 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -8,18 +8,28 @@ import Foundation import AppKit import CodeEditTextView +import Combine #warning("Replace before release") fileprivate let demoFoldProvider = IndentationLineFoldProvider() /// Displays the code folding ribbon in the ``GutterView``. -/// -/// This view draws its contents class FoldingRibbonView: NSView { + struct HoveringFold: Equatable { + let range: ClosedRange + let depth: Int + } + static let width: CGFloat = 7.0 - private var model: LineFoldingModel - private var hoveringLine: Int? + var model: LineFoldingModel + + // Disabling this lint rule because this initial value is required for @Invalidating + @Invalidating(.display) + var hoveringFold: HoveringFold? = nil // swiftlint:disable:this redundant_optional_initialization + var hoverAnimationTimer: Timer? + @Invalidating(.display) + var hoverAnimationProgress: CGFloat = 0.0 @Invalidating(.display) var backgroundColor: NSColor = NSColor.controlBackgroundColor @@ -30,7 +40,7 @@ class FoldingRibbonView: NSView { case .aqua: NSColor(deviceWhite: 0.0, alpha: 0.1) case .darkAqua: - NSColor(deviceWhite: 1.0, alpha: 0.1) + NSColor(deviceWhite: 1.0, alpha: 0.2) default: NSColor() } @@ -48,6 +58,32 @@ class FoldingRibbonView: NSView { } }.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 + + @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 + + private var foldUpdateCancellable: AnyCancellable? + override public var isFlipped: Bool { true } @@ -60,17 +96,28 @@ class FoldingRibbonView: NSView { ) super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay + clipsToBounds = false + + foldUpdateCancellable = model.foldsUpdatedPublisher.sink { + self.needsDisplay = true + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + foldUpdateCancellable?.cancel() + } + + // MARK: - Hover + override func updateTrackingAreas() { trackingAreas.forEach(removeTrackingArea) let area = NSTrackingArea( rect: bounds, - options: [.mouseMoved, .activeInKeyWindow], + options: [.mouseMoved, .activeInKeyWindow, .mouseEnteredAndExited], owner: self, userInfo: nil ) @@ -79,132 +126,45 @@ class FoldingRibbonView: NSView { override func mouseMoved(with event: NSEvent) { let pointInView = convert(event.locationInWindow, from: nil) - hoveringLine = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index - } - - /// The context in which the fold is being drawn, including the depth and fold range. - struct FoldMarkerDrawingContext { - let range: ClosedRange - let depth: UInt - - /// Increment the depth - func incrementDepth() -> FoldMarkerDrawingContext { - FoldMarkerDrawingContext( - range: range, - depth: depth + 1 - ) - } - } - - override func draw(_ dirtyRect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext, - let layoutManager = model.textView?.layoutManager else { + guard let lineNumber = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index, + let fold = model.getCachedFoldAt(lineNumber: lineNumber) else { + hoverAnimationProgress = 0.0 + hoveringFold = nil 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 { + let newHoverRange = HoveringFold(range: fold.range.lineRange, depth: fold.depth) + guard newHoverRange != hoveringFold else { return } - let lineRange = rangeStart.index...rangeEnd.index - - context.setFillColor(markerColor) - let folds = model.getFolds(in: lineRange) - for fold in folds { - drawFoldMarker( - fold, - markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0), - in: context, - using: layoutManager - ) - } - - context.restoreGState() - } - - /// Draw a single fold marker for a fold. - /// - /// Ensure the correct fill color is set on the drawing context before calling. - /// - /// - Parameters: - /// - fold: The fold to draw. - /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is - /// being hovered. - /// - context: The drawing context to use. - /// - layoutManager: A layout manager used to retrieve position information for lines. - private func drawFoldMarker( - _ fold: FoldRange, - markerContext: FoldMarkerDrawingContext, - in context: CGContext, - using layoutManager: TextLayoutManager - ) { - guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos, - let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else { - return - } - - let maxYPosition = maxPosition.yPos + maxPosition.height - - if false /*model.getCachedDepthAt(lineNumber: hoveringLine ?? -1)*/ { - // TODO: Handle hover state - } else { - let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) - // TODO: Draw a single horizontal line when folds are adjacent - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5) - - context.addPath(roundedRect.cgPathFallback) - context.drawPath(using: .fill) - - // Add small white line if we're overlapping with other markers - if markerContext.depth != 0 { - drawOutline( - minYPosition: minYPosition, - maxYPosition: maxYPosition, - originalPath: roundedRect, - in: context - ) + hoverAnimationTimer?.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 = newHoverRange + + 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() + } } + return } - // Draw subfolds - for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { - drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) - } + // Don't animate these + hoverAnimationProgress = 1.0 + hoveringFold = newHoverRange } - /// 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. - /// - /// - Parameters: - /// - minYPosition: The minimum y position of the rectangle to outline. - /// - maxYPosition: The maximum y position of the rectangle to outline. - /// - originalPath: The original bezier path for the rounded rectangle. - /// - context: The context to draw in. - private func drawOutline( - minYPosition: CGFloat, - maxYPosition: CGFloat, - originalPath: NSBezierPath, - in context: CGContext - ) { - context.saveGState() - - let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition) - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4) - - let combined = CGMutablePath() - combined.addPath(roundedRect.cgPathFallback) - combined.addPath(originalPath.cgPathFallback) - - context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) - context.addPath(combined) - context.setFillColor(markerBorderColor) - context.drawPath(using: .eoFill) - - context.restoreGState() + override func mouseExited(with event: NSEvent) { + hoverAnimationProgress = 0.0 + hoveringFold = nil } } diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift index b2e4dfbcf..31230af35 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -7,6 +7,7 @@ import AppKit import CodeEditTextView +import Combine /// # Basic Premise /// @@ -25,6 +26,8 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { weak var foldProvider: LineFoldProvider? weak var textView: TextView? + lazy var foldsUpdatedPublisher = PassthroughSubject() + init(textView: TextView, foldProvider: LineFoldProvider?) { self.textView = textView self.foldProvider = foldProvider @@ -55,7 +58,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { ) else { continue } - print(foldDepth, linePosition.index) + // Start a new fold if foldDepth > currentDepth { let newFold = FoldRange( @@ -64,25 +67,26 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { parent: currentFold, subFolds: [] ) - if currentDepth == 0 { + + if currentFold == nil { foldCache.append(newFold) + } else { + currentFold?.subFolds.append(newFold) } - currentFold?.subFolds.append(newFold) + currentFold = newFold } else if foldDepth < currentDepth { // End this fold if let fold = currentFold { fold.lineRange = fold.lineRange.lowerBound...linePosition.index - - if foldDepth == 0 { - currentFold = nil - } } currentFold = currentFold?.parent } currentDepth = foldDepth } + + foldsUpdatedPublisher.send() } func invalidateLine(lineNumber: Int) { @@ -109,20 +113,20 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { /// - Parameter lineNumber: The line number to query, zero-indexed. /// - Returns: The deepest cached depth of the fold if it was found. func getCachedDepthAt(lineNumber: Int) -> Int? { - return findCachedFoldAt(lineNumber: lineNumber)?.depth + return getCachedFoldAt(lineNumber: lineNumber)?.depth } -} -// MARK: - Search Folds - -private extension LineFoldingModel { /// Finds the deepest cached fold and depth of the fold for a line number. /// - Parameter lineNumber: The line number to query, zero-indexed. /// - Returns: The deepest cached fold and depth of the fold if it was found. - func findCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? { + func getCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? { binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0) } +} +// MARK: - Search Folds + +private extension LineFoldingModel { /// A generic function for searching an ordered array of fold ranges. /// - Returns: The found range and depth it was found at, if it exists. func binarySearchFoldsArray(