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 243527a2a..c511a9f74 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-1024.png b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-1024.png new file mode 100644 index 000000000..ece0b3c0f Binary files /dev/null and b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-1024.png differ diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-128.png b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-128.png new file mode 100644 index 000000000..396371895 Binary files /dev/null and b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-128.png differ diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-16.png b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-16.png new file mode 100644 index 000000000..82db99949 Binary files /dev/null and b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-16.png differ diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-256.png b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-256.png new file mode 100644 index 000000000..9bb2899d8 Binary files /dev/null and b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-256.png differ diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-32.png b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-32.png new file mode 100644 index 000000000..df4e783a6 Binary files /dev/null and b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-32.png differ diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-512.png b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-512.png new file mode 100644 index 000000000..86c449ebf Binary files /dev/null and b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-512.png differ diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-64.png b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-64.png new file mode 100644 index 000000000..6cbb425a7 Binary files /dev/null and b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/CodeEditSourceEditor-Icon-64.png differ diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db43e..f9486bdd6 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,58 +1,68 @@ { "images" : [ { + "size" : "16x16", "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" + "filename" : "CodeEditSourceEditor-Icon-16.png", + "scale" : "1x" }, { + "size" : "16x16", "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" + "filename" : "CodeEditSourceEditor-Icon-32.png", + "scale" : "2x" }, { + "size" : "32x32", "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "filename" : "CodeEditSourceEditor-Icon-32.png", + "scale" : "1x" }, { + "size" : "32x32", "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" + "filename" : "CodeEditSourceEditor-Icon-64.png", + "scale" : "2x" }, { + "size" : "128x128", "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "filename" : "CodeEditSourceEditor-Icon-128.png", + "scale" : "1x" }, { + "size" : "128x128", "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "filename" : "CodeEditSourceEditor-Icon-256.png", + "scale" : "2x" }, { + "size" : "256x256", "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "filename" : "CodeEditSourceEditor-Icon-256.png", + "scale" : "1x" }, { + "size" : "256x256", "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" + "filename" : "CodeEditSourceEditor-Icon-512.png", + "scale" : "2x" }, { + "size" : "512x512", "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "filename" : "CodeEditSourceEditor-Icon-512.png", + "scale" : "1x" }, { + "size" : "512x512", "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" + "filename" : "CodeEditSourceEditor-Icon-1024.png", + "scale" : "2x" } ], "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } -} +} \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 7296d78dd..db462e1ec 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 69556c288..1129c9d35 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,9 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( - url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.1" + path: "../CodeEditTextView" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.11.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index c49e4afe3..37497a49e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -26,7 +26,7 @@ extension TextViewController { font: font.rulerFont, textColor: theme.text.color.withAlphaComponent(0.35), selectedTextColor: theme.text.color, - textView: textView, + controller: self, delegate: self ) gutterView.updateWidthIfNeeded() @@ -143,6 +143,7 @@ extension TextViewController { - (self?.scrollView.contentInsets.top ?? 0) self?.gutterView.needsDisplay = true + self?.gutterView.foldingRibbon.needsDisplay = true self?.guideView?.updatePosition(in: textView) self?.scrollView.needsLayout = true } diff --git a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift index 0b14ea65a..b579e8d52 100644 --- a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift +++ b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift @@ -27,13 +27,11 @@ extension DispatchQueue { /// executed if not already on the main thread. /// - Parameter item: The work item to execute. /// - Returns: The value of the work item. - static func syncMainIfNot(_ item: @escaping () -> T) -> T { + static func waitMainIfNot(_ item: () -> T) -> T { if Thread.isMainThread { return item() } else { - return DispatchQueue.main.sync { - return item() - } + return DispatchQueue.main.asyncAndWait(execute: item) } } } diff --git a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift new file mode 100644 index 000000000..09156caea --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift @@ -0,0 +1,111 @@ +// +// NSBezierPath+RoundedCorners.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/3/25. +// + +import AppKit + +// Wonderful NSBezierPath extension taken with modification from the playground code at: +// https://github.com/janheiermann/BezierPath-Corners + +extension NSBezierPath { + struct Corners: OptionSet { + public let rawValue: Int + + public init(rawValue: Corners.RawValue) { + self.rawValue = rawValue + } + + public static let topLeft = Corners(rawValue: 1 << 0) + public static let bottomLeft = Corners(rawValue: 1 << 1) + public static let topRight = Corners(rawValue: 1 << 2) + public static let bottomRight = Corners(rawValue: 1 << 3) + } + + // swiftlint:disable:next function_body_length + convenience init(rect: CGRect, roundedCorners corners: Corners, cornerRadius: CGFloat) { + self.init() + + let maxX = rect.maxX + let minX = rect.minX + let maxY = rect.maxY + let minY = rect.minY + let radius = min(cornerRadius, min(rect.width, rect.height) / 2) + + // Start at bottom-left corner + move(to: CGPoint(x: minX + (corners.contains(.bottomLeft) ? radius : 0), y: minY)) + + // Bottom edge + if corners.contains(.bottomRight) { + line(to: CGPoint(x: maxX - radius, y: minY)) + appendArc( + withCenter: CGPoint(x: maxX - radius, y: minY + radius), + radius: radius, + startAngle: 270, + endAngle: 0, + clockwise: false + ) + } else { + line(to: CGPoint(x: maxX, y: minY)) + } + + // Right edge + if corners.contains(.topRight) { + line(to: CGPoint(x: maxX, y: maxY - radius)) + appendArc( + withCenter: CGPoint(x: maxX - radius, y: maxY - radius), + radius: radius, + startAngle: 0, + endAngle: 90, + clockwise: false + ) + } else { + line(to: CGPoint(x: maxX, y: maxY)) + } + + // Top edge + if corners.contains(.topLeft) { + line(to: CGPoint(x: minX + radius, y: maxY)) + appendArc( + withCenter: CGPoint(x: minX + radius, y: maxY - radius), + radius: radius, + startAngle: 90, + endAngle: 180, + clockwise: false + ) + } else { + line(to: CGPoint(x: minX, y: maxY)) + } + + // Left edge + if corners.contains(.bottomLeft) { + line(to: CGPoint(x: minX, y: minY + radius)) + appendArc( + withCenter: CGPoint(x: minX + radius, y: minY + radius), + radius: radius, + startAngle: 180, + endAngle: 270, + clockwise: false + ) + } else { + line(to: CGPoint(x: minX, y: minY)) + } + + close() + } + + convenience init(roundingRect: CGRect, capTop: Bool, capBottom: Bool, cornerRadius radius: CGFloat) { + switch (capTop, capBottom) { + case (true, true): + self.init(rect: roundingRect) + case (false, true): + self.init(rect: roundingRect, roundedCorners: [.bottomLeft, .bottomRight], cornerRadius: radius) + case (true, false): + self.init(rect: roundingRect, roundedCorners: [.topLeft, .topRight], cornerRadius: radius) + case (false, false): + self.init(roundedRect: roundingRect, xRadius: radius, yRadius: radius) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Equatable.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift rename to Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Equatable.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Helpers.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift rename to Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Helpers.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+LineHeight.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+LineHeight.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+RulerFont.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+RulerFont.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift b/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift new file mode 100644 index 000000000..870e11545 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift @@ -0,0 +1,19 @@ +// +// NSString+TextStory.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/3/25. +// + +import AppKit +import TextStory + +extension NSString: @retroactive TextStoring { + public func substring(from range: NSRange) -> String? { + self.substring(with: range) + } + + public func applyMutation(_ mutation: TextMutation) { + self.replacingCharacters(in: mutation.range, with: mutation.string) + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift index 38153e154..2e00dff36 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift @@ -30,7 +30,7 @@ extension TextView { let range = NSRange(location.. String? = { self?.textStorage.substring(from: range) } - return DispatchQueue.syncMainIfNot(workItem) + return DispatchQueue.waitMainIfNot(workItem) } } } diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 2a5125789..76d9152ee 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -101,7 +101,7 @@ public class GutterView: NSView { } /// The view that draws the fold decoration in the gutter. - private var foldingRibbon: FoldingRibbonView + var foldingRibbon: FoldingRibbonView /// Syntax helper for determining the required space for the folding ribbon. private var foldingRibbonWidth: CGFloat { @@ -137,16 +137,16 @@ public class GutterView: NSView { font: NSFont, textColor: NSColor, selectedTextColor: NSColor?, - textView: TextView, + controller: TextViewController, delegate: GutterViewDelegate? = nil ) { self.font = font self.textColor = textColor self.selectedLineTextColor = selectedTextColor ?? .secondaryLabelColor - self.textView = textView + self.textView = controller.textView self.delegate = delegate - foldingRibbon = FoldingRibbonView(textView: textView, foldProvider: nil) + foldingRibbon = FoldingRibbonView(controller: controller, foldProvider: nil) super.init(frame: .zero) clipsToBounds = true diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift deleted file mode 100644 index 03dbd9fa1..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// IndentationLineFoldProvider.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/8/25. -// - -import AppKit -import CodeEditTextView - -final class IndentationLineFoldProvider: LineFoldProvider { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { - guard let linePosition = layoutManager.textLineForIndex(lineNumber), - let indentLevel = indentLevelForPosition(linePosition, textStorage: textStorage) else { - return nil - } - - return indentLevel - } - - private func indentLevelForPosition( - _ position: TextLineStorage.TextLinePosition, - textStorage: NSTextStorage - ) -> Int? { - guard let substring = textStorage.substring(from: position.range) else { - return nil - } - - return substring.utf16 // Keep NSString units - .enumerated() - .first(where: { UnicodeScalar($0.element)?.properties.isWhitespace != true })? - .offset - } -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift deleted file mode 100644 index 714a48a06..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FoldRange.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import Foundation - -/// Represents a recursive folded range -class FoldRange { - var lineRange: ClosedRange - var range: NSRange - /// Ordered array of ranges that are nested in this fold. - var subFolds: [FoldRange] - - weak var parent: FoldRange? - - init(lineRange: ClosedRange, range: NSRange, parent: FoldRange?, subFolds: [FoldRange]) { - self.lineRange = lineRange - self.range = range - self.subFolds = subFolds - self.parent = parent - } -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift deleted file mode 100644 index d85a25916..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// 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/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift deleted file mode 100644 index 64a15ae71..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// LineFoldProvider.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import AppKit -import CodeEditTextView - -protocol LineFoldProvider: AnyObject { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift deleted file mode 100644 index 31230af35..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// LineFoldingModel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import AppKit -import CodeEditTextView -import Combine - -/// # Basic Premise -/// -/// We need to update, delete, or add fold ranges in the invalidated lines. -/// -/// # Implementation -/// -/// - For each line in the document, put its indent level into a list. -/// - Loop through the list, creating nested folds as indents go up and down. -/// -class LineFoldingModel: NSObject, NSTextStorageDelegate { - /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` - /// and ``FoldRange/subFolds``. - private var foldCache: [FoldRange] = [] - - weak var foldProvider: LineFoldProvider? - weak var textView: TextView? - - lazy var foldsUpdatedPublisher = PassthroughSubject() - - init(textView: TextView, foldProvider: LineFoldProvider?) { - self.textView = textView - self.foldProvider = foldProvider - super.init() - textView.addStorageDelegate(self) - buildFoldsForDocument() - } - - func getFolds(in lineRange: ClosedRange) -> [FoldRange] { - foldCache.filter({ $0.lineRange.overlaps(lineRange) }) - } - - /// Build out the ``foldCache`` for the entire document. - /// - /// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the - /// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in. - func buildFoldsForDocument() { - guard let textView, let foldProvider else { return } - foldCache.removeAll(keepingCapacity: true) - - var currentFold: FoldRange? - var currentDepth: Int = 0 - for linePosition in textView.layoutManager.linesInRange(textView.documentRange) { - guard let foldDepth = foldProvider.foldLevelAtLine( - linePosition.index, - layoutManager: textView.layoutManager, - textStorage: textView.textStorage - ) else { - continue - } - - // Start a new fold - if foldDepth > currentDepth { - let newFold = FoldRange( - lineRange: (linePosition.index - 1)...(linePosition.index - 1), - range: .zero, - parent: currentFold, - subFolds: [] - ) - - if currentFold == nil { - foldCache.append(newFold) - } else { - currentFold?.subFolds.append(newFold) - } - - currentFold = newFold - } else if foldDepth < currentDepth { - // End this fold - if let fold = currentFold { - fold.lineRange = fold.lineRange.lowerBound...linePosition.index - } - currentFold = currentFold?.parent - } - - currentDepth = foldDepth - } - - foldsUpdatedPublisher.send() - } - - func invalidateLine(lineNumber: Int) { - // TODO: Check if we need to rebuild, or even better, incrementally update the tree. - - // Temporary - buildFoldsForDocument() - } - - func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - guard editedMask.contains(.editedCharacters), - let lineNumber = textView?.layoutManager.textLineForOffset(editedRange.location)?.index else { - return - } - invalidateLine(lineNumber: lineNumber) - } - - /// Finds the deepest cached depth of the fold for a line number. - /// - 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 getCachedFoldAt(lineNumber: lineNumber)?.depth - } - - /// 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 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( - lineNumber: Int, - folds: borrowing [FoldRange], - currentDepth: Int - ) -> (range: FoldRange, depth: Int)? { - var low = 0 - var high = folds.count - 1 - - while low <= high { - let mid = (low + high) / 2 - let fold = folds[mid] - - if fold.lineRange.contains(lineNumber) { - // Search deeper into subFolds, if any - return binarySearchFoldsArray( - lineNumber: lineNumber, - folds: fold.subFolds, - currentDepth: currentDepth + 1 - ) ?? (fold, currentDepth) - } else if lineNumber < fold.lineRange.lowerBound { - high = mid - 1 - } else { - low = mid + 1 - } - } - return nil - } -} diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 678e2761f..50feee21f 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -223,23 +223,9 @@ extension Highlighter: NSTextStorageDelegate { ) { // This method is called whenever attributes are updated, so to avoid re-highlighting the entire document // each time an attribute is applied, we check to make sure this is in response to an edit. - guard editedMask.contains(.editedCharacters), let textView else { return } - - let styleContainerRange: Range - let newLength: Int - - if editedRange.length == 0 { // Deleting, editedRange is at beginning of the range that was deleted - styleContainerRange = editedRange.location..<(editedRange.location - delta) - newLength = 0 - } else { // Replacing or inserting - styleContainerRange = editedRange.location..<(editedRange.location + editedRange.length - delta) - newLength = editedRange.length - } + guard editedMask.contains(.editedCharacters) else { return } - styleContainer.storageUpdated( - replacedContentIn: styleContainerRange, - withCount: newLength - ) + styleContainer.storageUpdated(editedRange: editedRange, changeInLength: delta) if delta > 0 { visibleRangeProvider.visibleSet.insert(range: editedRange) diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index d61bfb009..95c81cb7e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -127,9 +127,9 @@ class StyledRangeContainer { return runs.reversed() } - func storageUpdated(replacedContentIn range: Range, withCount newLength: Int) { + func storageUpdated(editedRange: NSRange, changeInLength delta: Int) { for key in _storage.keys { - _storage[key]?.storageUpdated(replacedCharactersIn: range, withCount: newLength) + _storage[key]?.storageUpdated(editedRange: editedRange, changeInLength: delta) } } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift new file mode 100644 index 000000000..84626c971 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift @@ -0,0 +1,64 @@ +// +// IndentationLineFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +final class IndentationLineFoldProvider: LineFoldProvider { + func indentLevelAtLine(substring: NSString) -> Int? { + for idx in 0.. [LineFoldProviderLineInfo] { + let text = controller.textView.textStorage.string as NSString + guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesAndNewlines)?.length, + leadingIndent != lineRange.length else { + return [] + } + var foldIndicators: [LineFoldProviderLineInfo] = [] + + let leadingDepth = leadingIndent / controller.indentOption.charCount + if leadingDepth < previousDepth { + // End the fold at the start of whitespace + foldIndicators.append( + .endFold( + rangeEnd: lineRange.location + leadingIndent, + newDepth: leadingDepth + ) + ) + } + + // Check if the next line has more indent + let maxRange = NSRange(start: lineRange.max, end: text.length) + guard let nextIndent = text.leadingRange(in: maxRange, within: .whitespacesWithoutNewlines)?.length, + nextIndent > 0 else { + return foldIndicators + } + + if nextIndent > leadingIndent, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) { + foldIndicators.append( + .startFold( + rangeStart: trailingWhitespace.location, + newDepth: nextIndent / controller.indentOption.charCount + ) + ) + } + + return foldIndicators + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift new file mode 100644 index 000000000..2e829209f --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift @@ -0,0 +1,42 @@ +// +// LineFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView + +enum LineFoldProviderLineInfo { + case startFold(rangeStart: Int, newDepth: Int) + case endFold(rangeEnd: Int, newDepth: Int) + + var depth: Int { + switch self { + case .startFold(_, let newDepth): + return newDepth + case .endFold(_, let newDepth): + return newDepth + } + } + + var rangeIndice: Int { + switch self { + case .startFold(let rangeStart, _): + return rangeStart + case .endFold(let rangeEnd, _): + return rangeEnd + } + } +} + +@MainActor +protocol LineFoldProvider: AnyObject { + func foldLevelAtLine( + lineNumber: Int, + lineRange: NSRange, + previousDepth: Int, + controller: TextViewController + ) -> [LineFoldProviderLineInfo] +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift new file mode 100644 index 000000000..3a12022d0 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -0,0 +1,184 @@ +// +// LineFoldCalculator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView + +/// A utility that calculates foldable line ranges in a text document based on indentation depth. +/// +/// `LineFoldCalculator` observes text edits and rebuilds fold regions asynchronously. +actor LineFoldCalculator { + weak var foldProvider: LineFoldProvider? + weak var controller: TextViewController? + + var valueStream: AsyncStream + + 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. + /// - controller: The text controller to use for text and attachment fetching. + /// - textChangedStream: A stream of text changes, received as the document is edited. + init( + foldProvider: LineFoldProvider?, + controller: TextViewController, + textChangedStream: AsyncStream<(NSRange, Int)> + ) { + self.foldProvider = foldProvider + self.controller = controller + (valueStream, valueStreamContinuation) = AsyncStream.makeStream() + Task { await listenToTextChanges(textChangedStream: textChangedStream) } + } + + 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)>) { + textChangedTask = Task { + for await edit in textChangedStream { + await buildFoldsForDocument(afterEditIn: edit.0, delta: edit.1) + } + } + } + + /// Build out the folds for the entire document. + /// + /// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the + /// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in. + private func buildFoldsForDocument(afterEditIn: NSRange, delta: Int) async { + guard let controller = self.controller, let foldProvider = self.foldProvider else { return } + let documentRange = await controller.textView.documentRange + var foldCache: [LineFoldStorage.RawFold] = [] + // Depth: Open range + var openFolds: [Int: LineFoldStorage.RawFold] = [:] + var currentDepth: Int = 0 + var lineIterator = await ChunkedLineIterator( + controller: controller, + foldProvider: foldProvider, + textIterator: await controller.textView.layoutManager.lineStorage.makeIterator() + ) + + for await lineChunk in lineIterator { + for lineInfo in lineChunk { + // Start a new fold, going deeper to a new depth. + if lineInfo.depth > currentDepth { + let newFold = LineFoldStorage.RawFold( + depth: lineInfo.depth, + range: lineInfo.rangeIndice.. received depth + for openFold in openFolds.values.filter({ $0.depth > lineInfo.depth }) { + openFolds.removeValue(forKey: openFold.depth) + foldCache.append( + LineFoldStorage.RawFold( + depth: openFold.depth, + range: openFold.range.lowerBound.. LineFoldStorage.DepthStartPair? in + guard let attachment = attachmentBox.attachment as? LineFoldPlaceholder else { + return nil + } + return LineFoldStorage.DepthStartPair(depth: attachment.fold.depth, start: attachmentBox.range.location) + } + + let storage = LineFoldStorage( + documentLength: newFolds.max( + by: { $0.range.upperBound < $1.range.upperBound } + )?.range.upperBound ?? documentRange.length, + folds: newFolds.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }), + collapsedRanges: Set(attachments) + ) + valueStreamContinuation.yield(storage) + } + + /// Asynchronously gets more line information from the fold provider. + /// Runs on the main thread so all text-related calculations are safe with the main text storage. + /// + /// Has to be an `AsyncSequence` so it can be main actor isolated. + @MainActor + struct ChunkedLineIterator: AsyncSequence, AsyncIteratorProtocol { + var controller: TextViewController + var foldProvider: LineFoldProvider + private var previousDepth: Int = 0 + var textIterator: TextLineStorage.TextLineStorageIterator + + init( + controller: TextViewController, + foldProvider: LineFoldProvider, + textIterator: TextLineStorage.TextLineStorageIterator + ) { + self.controller = controller + self.foldProvider = foldProvider + self.textIterator = textIterator + } + + nonisolated func makeAsyncIterator() -> ChunkedLineIterator { + self + } + + mutating func next() -> [LineFoldProviderLineInfo]? { + var results: [LineFoldProviderLineInfo] = [] + var count = 0 + var previousDepth: Int = previousDepth + while count < 50, let linePosition = textIterator.next() { + let foldInfo = foldProvider.foldLevelAtLine( + lineNumber: linePosition.index, + lineRange: linePosition.range, + previousDepth: previousDepth, + controller: controller + ) + results.append(contentsOf: foldInfo) + count += 1 + previousDepth = foldInfo.max(by: { $0.depth < $1.depth })?.depth ?? previousDepth + } + if results.isEmpty && count == 0 { + return nil + } + return results + } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift new file mode 100644 index 000000000..52b5d78dd --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -0,0 +1,123 @@ +// +// LineFoldStorage.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// +import _RopeModule +import Foundation + +/// Represents a single fold region with stable identifier and collapse state +struct FoldRange: Sendable, Equatable { + typealias FoldIdentifier = UInt32 + + let id: FoldIdentifier + let depth: Int + let range: Range + var isCollapsed: Bool + + func isHoveringEqual(_ other: FoldRange) -> Bool { + depth == other.depth && range.contains(other.range) + } +} + +/// Sendable data model for code folding using RangeStore +struct LineFoldStorage: Sendable { + /// A temporary fold representation without stable ID + struct RawFold: Sendable { + let depth: Int + let range: Range + } + + struct DepthStartPair: Hashable { + let depth: Int + let start: Int + } + + /// Element stored in RangeStore: holds reference to a fold region + struct FoldStoreElement: RangeStoreElement, Sendable { + let id: FoldRange.FoldIdentifier + let depth: Int + + var isEmpty: Bool { false } + } + + private var idCounter = FoldRange.FoldIdentifier.zero + private var store: RangeStore + private var foldRanges: [FoldRange.FoldIdentifier: FoldRange] = [:] + + /// Initialize with the full document length + init(documentLength: Int, folds: [RawFold] = [], collapsedRanges: Set = []) { + self.store = RangeStore(documentLength: documentLength) + self.updateFolds(from: folds, collapsedRanges: collapsedRanges) + } + + private mutating func nextFoldId() -> FoldRange.FoldIdentifier { + idCounter += 1 + return idCounter + } + + /// Replace all fold data from raw folds, preserving collapse state via callback + /// - Parameter rawFolds: newly computed folds (depth + range) + /// - Parameter collapsedRanges: Current collapsed ranges/depths + mutating func updateFolds(from rawFolds: [RawFold], collapsedRanges: Set) { + // Build reuse map by start+depth, carry over collapse state + var reuseMap: [DepthStartPair: FoldRange] = [:] + for region in foldRanges.values { + reuseMap[DepthStartPair(depth: region.depth, start: region.range.lowerBound)] = region + } + + // Build new regions + foldRanges.removeAll(keepingCapacity: true) + store = RangeStore(documentLength: store.length) + + for raw in rawFolds { + let key = DepthStartPair(depth: raw.depth, start: raw.range.lowerBound) + // reuse id and collapse state if available + let prior = reuseMap[key] + let id = prior?.id ?? nextFoldId() + let wasCollapsed = prior?.isCollapsed ?? false + // override collapse if provider says so + let isCollapsed = collapsedRanges.contains(key) || wasCollapsed + let fold = FoldRange(id: id, depth: raw.depth, range: raw.range, isCollapsed: isCollapsed) + + foldRanges[id] = fold + let elem = FoldStoreElement(id: id, depth: raw.depth) + store.set(value: elem, for: raw.range) + } + } + + /// Keep folding offsets in sync after text edits + mutating func storageUpdated(editedRange: NSRange, changeInLength delta: Int) { + store.storageUpdated(editedRange: editedRange, changeInLength: delta) + } + + mutating func toggleCollapse(forFold fold: FoldRange) { + guard var existingRange = foldRanges[fold.id] else { return } + existingRange.isCollapsed.toggle() + foldRanges[fold.id] = existingRange + } + + /// Query a document subrange and return all folds as an ordered list by start position + func folds(in queryRange: Range) -> [FoldRange] { + let runs = store.runs(in: queryRange.clamped(to: 0.. = [] + var result: [FoldRange] = [] + + for run in runs { + if let elem = run.value, !alreadyReturnedIDs.contains(elem.id), let range = foldRanges[elem.id] { + result.append( + FoldRange( + id: elem.id, + depth: elem.depth, + range: range.range, + isCollapsed: range.isCollapsed + ) + ) + alreadyReturnedIDs.insert(elem.id) + } + } + + return result.sorted { $0.range.lowerBound < $1.range.lowerBound } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift new file mode 100644 index 000000000..08137c418 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -0,0 +1,95 @@ +// +// LineFoldingModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView +import Combine + +/// # Basic Premise +/// +/// We need to update, delete, or add fold ranges in the invalidated lines. +/// +/// # Implementation +/// +/// - For each line in the document, put its indent level into a list. +/// - Loop through the list, creating nested folds as indents go up and down. +/// +class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { + /// 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) + private var calculator: LineFoldCalculator + + private var textChangedStream: AsyncStream<(NSRange, Int)> + private var textChangedStreamContinuation: AsyncStream<(NSRange, Int)>.Continuation + private var cacheListenTask: Task? + + weak var controller: TextViewController? + + init(controller: TextViewController, foldView: NSView, foldProvider: LineFoldProvider?) { + self.controller = controller + (textChangedStream, textChangedStreamContinuation) = AsyncStream<(NSRange, Int)>.makeStream() + self.calculator = LineFoldCalculator( + foldProvider: foldProvider, + controller: controller, + textChangedStream: textChangedStream + ) + super.init() + controller.textView.addStorageDelegate(self) + + cacheListenTask = Task { @MainActor [weak foldView] in + for await newFolds in await calculator.valueStream { + foldCache = newFolds + foldView?.needsDisplay = true + } + } + textChangedStreamContinuation.yield((.zero, 0)) + } + + func getFolds(in range: Range) -> [FoldRange] { + foldCache.folds(in: range) + } + + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { + return + } + foldCache.storageUpdated(editedRange: editedRange, changeInLength: delta) + textChangedStreamContinuation.yield((editedRange, delta)) + } + + /// Finds the deepest cached depth of the fold for a line number. + /// - 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 getCachedFoldAt(lineNumber: lineNumber)?.depth + } + + /// 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 getCachedFoldAt(lineNumber: Int) -> FoldRange? { + guard let lineRange = controller?.textView.layoutManager.textLineForIndex(lineNumber)?.range else { return nil } + guard let deepestFold = foldCache.folds(in: lineRange.intRange).max(by: { + if $0.isCollapsed != $1.isCollapsed { + $1.isCollapsed // Collapsed folds take precedence. + } else if $0.isCollapsed { + $0.depth > $1.depth + } else { + $0.depth < $1.depth + } + }) else { + return nil + } + return deepestFold + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift new file mode 100644 index 000000000..40ecd262c --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -0,0 +1,33 @@ +// +// LineFoldPlaceholder.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView + +class LineFoldPlaceholder: TextAttachment { + let fold: FoldRange + + init(fold: FoldRange) { + self.fold = fold + } + + var width: CGFloat { 17 } + + func draw(in context: CGContext, rect: NSRect) { + context.saveGState() + + let centerY = rect.midY - 1.5 + + 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)) + context.fillPath() + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift new file mode 100644 index 000000000..1a0650291 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -0,0 +1,298 @@ +// +// FoldingRibbonView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +extension FoldingRibbonView { + struct DrawingFoldInfo { + let fold: FoldRange + let startLine: TextLineStorage.TextLinePosition + let endLine: TextLineStorage.TextLinePosition + } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext, + let layoutManager = model?.controller?.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 textRange = rangeStart.range.location.., + layoutManager: TextLayoutManager + ) -> [DrawingFoldInfo] { + var folds = model?.getFolds(in: textRange) ?? [] + + // Add in some fake depths, we can draw these underneath the rest of the folds to make it look like it's + // continuous + if let minimumDepth = folds.min(by: { $0.depth < $1.depth })?.depth { + for depth in (1.., + 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 roundedRect = NSBezierPath( + roundingRect: plainRect, + capTop: foldCaps.foldNeedsTopCap(foldInfo), + capBottom: foldCaps.foldNeedsBottomCap(foldInfo), + cornerRadius: radius + ) + roundedRect.transform(using: .init(translationByX: -0.5, 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.addPath(combined) + context.setFillColor(markerBorderColor) + context.drawPath(using: .eoFill) + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift new file mode 100644 index 000000000..1c766027a --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift @@ -0,0 +1,50 @@ +// +// FoldCapInfo.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/3/25. +// + +import AppKit + +extension FoldingRibbonView { + struct FoldCapInfo { + let startIndices: Set + let endIndices: 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) }) + } + + func foldNeedsTopCap(_ fold: DrawingFoldInfo) -> Bool { + endIndices.contains(fold.startLine.index) + } + + func foldNeedsBottomCap(_ fold: DrawingFoldInfo) -> Bool { + startIndices.contains(fold.endLine.index) + } + + func adjustFoldRect( + using fold: DrawingFoldInfo, + rect: NSRect + ) -> 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) + } else { + 0.0 + } + return NSRect( + x: rect.origin.x, + y: rect.origin.y + yDelta, + width: rect.size.width, + height: rect.size.height + heightDelta + ) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift similarity index 60% rename from Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift rename to Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index c06c9ac03..e83790d68 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -10,23 +10,21 @@ 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 - } + +#warning("Replace before release") + private static let demoFoldProvider = IndentationLineFoldProvider() static let width: CGFloat = 7.0 - var model: LineFoldingModel + 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 hoveringFold: FoldRange? = nil // swiftlint:disable:this redundant_optional_initialization var hoverAnimationTimer: Timer? @Invalidating(.display) var hoverAnimationProgress: CGFloat = 0.0 @@ -82,33 +80,30 @@ class FoldingRibbonView: NSView { } }.cgColor - private var foldUpdateCancellable: AnyCancellable? - override public var isFlipped: Bool { true } - init(textView: TextView, foldProvider: LineFoldProvider?) { - #warning("Replace before release") - self.model = LineFoldingModel( - textView: textView, - foldProvider: foldProvider ?? demoFoldProvider - ) + init(controller: TextViewController, foldProvider: LineFoldProvider?) { super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay clipsToBounds = false - foldUpdateCancellable = model.foldsUpdatedPublisher.sink { - self.needsDisplay = true - } + #warning("Replace before release") + self.model = LineFoldingModel( + controller: controller, + foldView: self, + foldProvider: foldProvider ?? Self.demoFoldProvider + ) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - deinit { - foldUpdateCancellable?.cancel() + override public func resetCursorRects() { + // Don't use an iBeam in this view + addCursorRect(bounds, cursor: .arrow) } // MARK: - Hover @@ -124,17 +119,59 @@ class FoldingRibbonView: NSView { addTrackingArea(area) } + var attachments: [LineFoldPlaceholder] = [] + + override func scrollWheel(with event: NSEvent) { + super.scrollWheel(with: event) + self.mouseMoved(with: event) + } + + override func mouseDown(with event: NSEvent) { + let clickPoint = convert(event.locationInWindow, from: nil) + guard let layoutManager = model?.controller?.textView.layoutManager, + event.type == .leftMouseDown, + let lineNumber = layoutManager.textLineForPosition(clickPoint.y)?.index, + let fold = model?.getCachedFoldAt(lineNumber: lineNumber), + let firstLineInFold = layoutManager.textLineForOffset(fold.range.lowerBound) else { + super.mouseDown(with: event) + return + } + + if let attachment = findAttachmentFor(fold: fold, firstLineRange: firstLineInFold.range) { + layoutManager.attachments.remove(atOffset: attachment.range.location) + attachments.removeAll(where: { $0 === attachment.attachment }) + } else { + let placeholder = LineFoldPlaceholder(fold: fold) + layoutManager.attachments.add(placeholder, for: NSRange(fold.range)) + attachments.append(placeholder) + } + + model?.foldCache.toggleCollapse(forFold: fold) + model?.controller?.textView.needsLayout = true + } + + private func findAttachmentFor(fold: FoldRange, firstLineRange: NSRange) -> AnyTextAttachment? { + model?.controller?.textView?.layoutManager.attachments + .getAttachmentsStartingIn(NSRange(fold.range)) + .filter({ + $0.attachment is LineFoldPlaceholder && firstLineRange.contains($0.range.location) + }).first + } + override func mouseMoved(with event: NSEvent) { + defer { + super.mouseMoved(with: event) + } + let pointInView = convert(event.locationInWindow, from: nil) - guard let lineNumber = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index, - let fold = model.getCachedFoldAt(lineNumber: lineNumber) else { + guard let lineNumber = model?.controller?.textView.layoutManager.textLineForPosition(pointInView.y)?.index, + let fold = model?.getCachedFoldAt(lineNumber: lineNumber) else { hoverAnimationProgress = 0.0 hoveringFold = nil return } - let newHoverRange = HoveringFold(range: fold.range.lineRange, depth: fold.depth) - guard newHoverRange != hoveringFold else { + guard fold.range != hoveringFold?.range else { return } hoverAnimationTimer?.invalidate() @@ -142,7 +179,7 @@ class FoldingRibbonView: NSView { // show it immediately. if hoveringFold == nil { hoverAnimationProgress = 0.0 - hoveringFold = newHoverRange + hoveringFold = fold let duration: TimeInterval = 0.2 let startTime = CACurrentMediaTime() @@ -160,10 +197,11 @@ class FoldingRibbonView: NSView { // Don't animate these hoverAnimationProgress = 1.0 - hoveringFold = newHoverRange + hoveringFold = fold } override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) hoverAnimationProgress = 0.0 hoveringFold = nil } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift index 363768d2e..0c3438e09 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift @@ -12,4 +12,11 @@ extension RangeStore { func findIndex(at offset: Int) -> (index: Index, remaining: Int) { _guts.find(at: offset, in: OffsetMetric(), preferEnd: false) } + + /// Finds the value stored at a given string offset. + /// - Parameter offset: The offset to query for. + /// - Returns: The element stored, if any. + func findValue(at offset: Int) -> Element? { + _guts[findIndex(at: offset).index].value + } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 5e22fc5f8..164cf1ad2 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift @@ -6,6 +6,7 @@ // import _RopeModule +import Foundation /// RangeStore is a container type that allows for setting and querying values for relative ranges in text. The /// container reflects a text document in that its length needs to be kept up-to-date. It can efficiently remove and @@ -49,7 +50,7 @@ struct RangeStore: Sendable { var index = findIndex(at: range.lowerBound).index var offset: Int? = range.lowerBound - _guts.offset(of: index, in: OffsetMetric()) - while index < _guts.endIndex { + while index < _guts.endIndex, _guts.offset(of: index, in: OffsetMetric()) < range.upperBound { let run = _guts[index] runs.append(Run(length: run.length - (offset ?? 0), value: run.value)) @@ -96,6 +97,22 @@ struct RangeStore: Sendable { // MARK: - Storage Sync extension RangeStore { + /// Handles keeping the internal storage in sync with the document. + mutating func storageUpdated(editedRange: NSRange, changeInLength delta: Int) { + let storageRange: Range + let newLength: Int + + if editedRange.length == 0 { // Deleting, editedRange is at beginning of the range that was deleted + storageRange = editedRange.location..<(editedRange.location - delta) + newLength = 0 + } else { // Replacing or inserting + storageRange = editedRange.location..<(editedRange.location + editedRange.length - delta) + newLength = editedRange.length + } + + storageUpdated(replacedCharactersIn: storageRange, withCount: newLength) + } + /// Handles keeping the internal storage in sync with the document. mutating func storageUpdated(replacedCharactersIn range: Range, withCount newLength: Int) { assert(range.lowerBound >= 0, "Negative lowerBound") diff --git a/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift b/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift index 4de83868e..b2815fbf6 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift @@ -22,6 +22,10 @@ final package class Atomic { } } + func withValue(_ handler: (T) -> F) -> F { + lock.withLock { handler(wrappedValue) } + } + func value() -> T { lock.withLock { wrappedValue } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift index ce67a0c93..89d934914 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift @@ -158,7 +158,7 @@ public class LanguageLayer: Hashable { } wasLongParse = true } - newTree = DispatchQueue.syncMainIfNot { parser.parse(tree: tree, readBlock: readBlock) } + newTree = DispatchQueue.waitMainIfNot { parser.parse(tree: tree, readBlock: readBlock) } } if wasLongParse { diff --git a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift deleted file mode 100644 index ba2eb1530..000000000 --- a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// FindPanelViewModelTests.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/25/25. -// - -import Testing -import AppKit -import CodeEditTextView -@testable import CodeEditSourceEditor - -@MainActor -struct FindPanelViewModelTests { - class MockPanelTarget: FindPanelTarget { - var emphasisManager: EmphasisManager? - var text: String = "" - var findPanelTargetView: NSView - var cursorPositions: [CursorPosition] = [] - var textView: TextView! - - @MainActor init() { - findPanelTargetView = NSView() - textView = TextView(string: text) - } - - func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) { } - func updateCursorPosition() { } - func findPanelWillShow(panelHeight: CGFloat) { } - func findPanelWillHide(panelHeight: CGFloat) { } - func findPanelModeDidChange(to mode: FindPanelMode) { } - } - - @Test func viewModelHeightUpdates() async throws { - let model = FindPanelViewModel(target: MockPanelTarget()) - model.mode = .find - #expect(model.panelHeight == 28) - - model.mode = .replace - #expect(model.panelHeight == 54) - } -} diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift new file mode 100644 index 000000000..44467bde2 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift @@ -0,0 +1,53 @@ +// +// LineFoldStorageTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/29/25. +// + +import Testing +@testable import CodeEditSourceEditor + +struct LineFoldStorageTests { + // Helper to create a collapsed provider set + private func collapsedSet(_ items: (Int, Int)...) -> Set { + Set(items.map { (depth, start) in + LineFoldStorage.DepthStartPair(depth: depth, start: start) + }) + } + + @Test + func emptyStorage() { + let storage = LineFoldStorage(documentLength: 50) + let folds = storage.folds(in: 0..<50) + #expect(folds.isEmpty) + } + + @Test + func updateFoldsBasic() { + var storage = LineFoldStorage(documentLength: 20) + let raw: [LineFoldStorage.RawFold] = [ + LineFoldStorage.RawFold(depth: 1, range: 0..<5), + LineFoldStorage.RawFold(depth: 2, range: 5..<10) + ] + storage.updateFolds(from: raw, collapsedRanges: []) + + let folds = storage.folds(in: 0..<20) + #expect(folds.count == 2) + #expect(folds[0].depth == 1 && folds[0].range == 0..<5 && folds[0].isCollapsed == false) + #expect(folds[1].depth == 2 && folds[1].range == 5..<10 && folds[1].isCollapsed == false) + } + + @Test + func preserveCollapseState() { + var storage = LineFoldStorage(documentLength: 15) + let raw = [LineFoldStorage.RawFold(depth: 1, range: 0..<5)] + // First pass: no collapsed + storage.updateFolds(from: raw, collapsedRanges: []) + #expect(storage.folds(in: 0..<15).first?.isCollapsed == false) + + // Second pass: provider marks depth=1, start=0 as collapsed + storage.updateFolds(from: raw, collapsedRanges: collapsedSet((1, 0))) + #expect(storage.folds(in: 0..<15).first?.isCollapsed == true) + } +} diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift index 4ba76f767..168feab50 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -10,46 +10,60 @@ import AppKit import CodeEditTextView @testable import CodeEditSourceEditor -@Suite @MainActor struct LineFoldingModelTests { /// Makes a fold pattern that increases until halfway through the document then goes back to zero. + @MainActor class HillPatternFoldProvider: LineFoldProvider { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { - let halfLineCount = (layoutManager.lineCount / 2) - 1 + func foldLevelAtLine( + lineNumber: Int, + lineRange: NSRange, + previousDepth: Int, + controller: TextViewController + ) -> [LineFoldProviderLineInfo] { + let halfLineCount = (controller.textView.layoutManager.lineCount / 2) - 1 return if lineNumber > halfLineCount { - layoutManager.lineCount - 2 - lineNumber + [ + .startFold( + rangeStart: lineRange.location, + newDepth: controller.textView.layoutManager.lineCount - 2 - lineNumber + ) + ] } else { - lineNumber + [ + .endFold(rangeEnd: lineRange.location, newDepth: lineNumber) + ] } } } + let controller: TextViewController let textView: TextView - let model: LineFoldingModel init() { - textView = TextView(string: "A\nB\nC\nD\nE\nF\n") + controller = Mock.textViewController(theme: Mock.theme()) + textView = controller.textView + textView.string = "A\nB\nC\nD\nE\nF\n" textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) textView.updatedViewport(NSRect(x: 0, y: 0, width: 1000, height: 1000)) - model = LineFoldingModel(textView: textView, foldProvider: nil) } /// A little unintuitive but we only expect two folds with this. Our provider goes 0-1-2-2-1-0, but we don't /// make folds for indent level 0. We also expect folds to start on the lines *before* the indent increases and /// after it decreases, so the fold covers the start/end of the region being folded. @Test - func buildFoldsForDocument() throws { + func buildFoldsForDocument() async throws { let provider = HillPatternFoldProvider() - model.foldProvider = provider + let model = LineFoldingModel(controller: controller, foldView: NSView(), foldProvider: provider) - model.buildFoldsForDocument() + var cacheUpdated = model.$foldCache.values.makeAsyncIterator() + _ = await cacheUpdated.next() + _ = await cacheUpdated.next() - let fold = try #require(model.getFolds(in: 0...5).first) - #expect(fold.lineRange == 0...5) - - let innerFold = try #require(fold.subFolds.first) - #expect(innerFold.lineRange == 1...4) + let fold = try #require(model.getFolds(in: 0..<6).first) + #expect(fold.range == 2..<10) + #expect(fold.depth == 1) + #expect(fold.isCollapsed == false) } }