From 4a73315c980565b206d4407b76069fe7d29a77a1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 09:29:15 -0500 Subject: [PATCH 01/33] Initial Commit - Ribbon View & Toggles --- .../xcshareddata/swiftpm/Package.resolved | 9 - .../Views/ContentView.swift | 7 +- .../Views/StatusBar.swift | 2 + Package.swift | 5 +- .../CodeEditSourceEditor.swift | 28 ++- ...extViewController+GutterViewDelegate.swift | 5 +- ...ift => TextViewController+Lifecycle.swift} | 12 +- .../TextViewController+StyleViews.swift | 1 + .../Controller/TextViewController.swift | 28 +-- .../TextView+/TextView+TextFormation.swift | 1 + .../Gutter/GutterView.swift | 103 +++++++--- .../Gutter/LineFolding/FoldRange.swift | 25 +++ .../LineFolding/FoldingRibbonView.swift | 193 ++++++++++++++++++ .../Gutter/LineFolding/LineFoldProvider.swift | 13 ++ .../Gutter/LineFolding/LineFoldingModel.swift | 151 ++++++++++++++ .../Minimap/MinimapLineRenderer.swift | 12 +- Tests/CodeEditSourceEditorTests/Mock.swift | 3 +- .../TextViewControllerTests.swift | 64 ++++-- 18 files changed, 572 insertions(+), 90 deletions(-) rename Sources/CodeEditSourceEditor/Controller/{TextViewController+LoadView.swift => TextViewController+Lifecycle.swift} (97%) create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift 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 a1eb3b548..e56a9beac 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", - "version" : "0.10.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 8a42a5f1a..640476346 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -27,6 +27,7 @@ struct ContentView: View { @State private var settingsIsPresented: Bool = false @State private var treeSitterClient = TreeSitterClient() @AppStorage("showMinimap") private var showMinimap: Bool = true + @AppStorage("showFoldingRibbon") private var showFoldingRibbon: Bool = true @State private var indentOption: IndentOption = .spaces(count: 4) @AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80 @AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false @@ -56,7 +57,8 @@ struct ContentView: View { useSystemCursor: useSystemCursor, showMinimap: showMinimap, reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + showFoldingRibbon: showFoldingRibbon ) .overlay(alignment: .bottom) { StatusBar( @@ -71,7 +73,8 @@ struct ContentView: View { showMinimap: $showMinimap, indentOption: $indentOption, reformatAtColumn: $reformatAtColumn, - showReformattingGuide: $showReformattingGuide + showReformattingGuide: $showReformattingGuide, + showFoldingRibbon: $showFoldingRibbon ) } .ignoresSafeArea() diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 779f5cd35..78721fbb8 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -26,6 +26,7 @@ struct StatusBar: View { @Binding var indentOption: IndentOption @Binding var reformatAtColumn: Int @Binding var showReformattingGuide: Bool + @Binding var showFoldingRibbon: Bool var body: some View { HStack { @@ -43,6 +44,7 @@ struct StatusBar: View { .onChange(of: reformatAtColumn) { _, newValue in reformatAtColumn = max(1, min(200, newValue)) } + Toggle("Show Folding Ribbon", isOn: $showFoldingRibbon) if #available(macOS 14, *) { Toggle("Use System Cursor", isOn: $useSystemCursor) } else { diff --git a/Package.swift b/Package.swift index a14085dcf..8d207d2a7 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.10.1" + path: "../CodeEditTextView" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.10.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 12a0f5a10..3e98891a7 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -39,7 +39,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// the default `TreeSitterClient` highlighter. /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. - /// - additionalTextInsets: An additional amount to inset the text of the editor by. + /// - additionalTextInsets: A set of extra text insets to indent only *text* content. Does not effect + /// decorations like the find panel. /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this /// value is true, and `isEditable` is false, the editor is selectable but not editable. @@ -47,12 +48,13 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` - /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. + /// - useSystemCursor: Use the system cursor instead of the default line cursor. Only available after macOS 14. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. - /// - showMinimap: Whether to show the minimap + /// - showMinimap: Toggle the visibility of the minimap. /// - reformatAtColumn: The column to reformat at /// - showReformattingGuide: Whether to show the reformatting guide + /// - showFoldingRibbon: Toggle the visibility of the line folding ribbon. public init( _ text: Binding, language: CodeLanguage, @@ -77,7 +79,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + showFoldingRibbon: Bool ) { self.text = .binding(text) self.language = language @@ -107,6 +110,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon } /// Initializes a Text Editor @@ -127,6 +131,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// the default `TreeSitterClient` highlighter. /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. + /// - additionalTextInsets: A set of extra text insets to indent only *text* content. Does not effect + /// decorations like the find panel. /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this /// value is true, and `isEditable` is false, the editor is selectable but not editable. @@ -134,11 +140,13 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. /// See `BracketPairEmphasis` for more information. Defaults to `nil` + /// - useSystemCursor: Use the system cursor instead of the default line cursor. Only available after macOS 14. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. - /// - showMinimap: Whether to show the minimap + /// - showMinimap: Toggle the visibility of the minimap. /// - reformatAtColumn: The column to reformat at /// - showReformattingGuide: Whether to show the reformatting guide + /// - showFoldingRibbon: Toggle the visibility of the line folding ribbon. public init( _ text: NSTextStorage, language: CodeLanguage, @@ -163,7 +171,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + showFoldingRibbon: Bool ) { self.text = .storage(text) self.language = language @@ -193,6 +202,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon } package var text: TextAPI @@ -219,6 +229,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { package var showMinimap: Bool private var reformatAtColumn: Int private var showReformattingGuide: Bool + package var showFoldingRibbon: Bool public typealias NSViewControllerType = TextViewController @@ -247,7 +258,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: coordinators, showMinimap: showMinimap, reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + showFoldingRibbon: showFoldingRibbon ) switch text { case .binding(let binding): @@ -336,6 +348,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.contentInsets = contentInsets controller.additionalTextInsets = additionalTextInsets controller.showMinimap = showMinimap + controller.showFoldingRibbon = showFoldingRibbon if controller.indentOption != indentOption { controller.indentOption = indentOption @@ -397,6 +410,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.showMinimap == showMinimap && controller.reformatAtColumn == reformatAtColumn && controller.showReformattingGuide == showReformattingGuide && + controller.showFoldingRibbon == showFoldingRibbon && areHighlightProvidersEqual(controller: controller, coordinator: coordinator) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift index 20abe130c..d0abfaaa8 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift @@ -8,8 +8,7 @@ import Foundation extension TextViewController: GutterViewDelegate { - public func gutterViewWidthDidUpdate(newWidth: CGFloat) { - gutterView?.frame.size.width = newWidth - textView?.textInsets = textViewInsets + public func gutterViewWidthDidUpdate() { + updateTextInsets() } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift similarity index 97% rename from Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 1b960ed48..86384a6b2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -9,6 +9,13 @@ import CodeEditTextView import AppKit extension TextViewController { + override public func viewWillAppear() { + super.viewWillAppear() + // The calculation this causes cannot be done until the view knows it's final position + updateTextInsets() + minimapView.layout() + } + override public func loadView() { super.loadView() @@ -106,9 +113,7 @@ extension TextViewController { object: scrollView.contentView, queue: .main ) { [weak self] notification in - guard let clipView = notification.object as? NSClipView, - let textView = self?.textView else { return } - textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + guard let clipView = notification.object as? NSClipView else { return } self?.gutterView.needsDisplay = true self?.minimapXConstraint?.constant = clipView.bounds.origin.x } @@ -120,7 +125,6 @@ extension TextViewController { object: scrollView.contentView, queue: .main ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) self?.gutterView.needsDisplay = true self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) self?.updateTextInsets() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 2cc2f13b5..579d5c4ef 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -56,6 +56,7 @@ extension TextViewController { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear } + gutterView.showFoldingRibbon = showFoldingRibbon } /// Style the scroll view. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 8d3b8b69f..758e5dc74 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -204,6 +204,13 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } + /// Toggles the line folding ribbon in the gutter view. + public var showFoldingRibbon: Bool { + didSet { + gutterView?.showFoldingRibbon = showFoldingRibbon + } + } + var textCoordinators: [WeakCoordinator] = [] var highlighter: Highlighter? @@ -229,7 +236,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty package var textViewInsets: HorizontalEdgeInsets { HorizontalEdgeInsets( - left: gutterView.gutterWidth, + left: gutterView?.frame.width ?? 0.0, right: textViewTrailingInset ) } @@ -265,6 +272,9 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } + /// A default `NSParagraphStyle` with a set `lineHeight` + package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + // MARK: Init init( @@ -291,7 +301,8 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty coordinators: [TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int = 80, - showReformattingGuide: Bool = false + showReformattingGuide: Bool = false, + showFoldingRibbon: Bool ) { self.language = language self.font = font @@ -314,6 +325,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon super.init(nibName: nil, bundle: nil) @@ -362,18 +374,6 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.gutterView.setNeedsDisplay(self.gutterView.frame) } - // MARK: Paragraph Style - - /// A default `NSParagraphStyle` with a set `lineHeight` - package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() - - override public func viewWillAppear() { - super.viewWillAppear() - // The calculation this causes cannot be done until the view knows it's final position - updateTextInsets() - minimapView.layout() - } - deinit { if let highlighter { textView.removeStorageDelegate(highlighter) diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 346410874..853773412 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -45,5 +45,6 @@ extension TextView: TextInterface { in: mutation.range, replacementLength: (mutation.string as NSString).length ) + layoutManager.setNeedsLayout() } } diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 0d9cf5b04..a8ca75fb2 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -10,7 +10,7 @@ import CodeEditTextView import CodeEditTextViewObjC public protocol GutterViewDelegate: AnyObject { - func gutterViewWidthDidUpdate(newWidth: CGFloat) + func gutterViewWidthDidUpdate() } /// The gutter view displays line numbers that match the text view's line indexes. @@ -69,12 +69,17 @@ public class GutterView: NSView { @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - /// The required width of the entire gutter, including padding. - private(set) public var gutterWidth: CGFloat = 0 + /// Toggle the visibility of the line fold decoration. + @Invalidating(.display) + public var showFoldingRibbon: Bool = true { + didSet { + foldingRibbon.isHidden = !showFoldingRibbon + } + } private weak var textView: TextView? private weak var delegate: GutterViewDelegate? - private var maxWidth: CGFloat = 0 + private var maxLineNumberWidth: CGFloat = 0 /// The maximum number of digits found for a line number. private var maxLineLength: Int = 0 @@ -91,10 +96,35 @@ public class GutterView: NSView { fontLineHeight = (ascent + descent + leading) } + /// The view that draws the fold decoration in the gutter. + private var foldingRibbon: FoldingRibbonView + + /// Syntax helper for determining the required space for the folding ribbon. + private var foldingRibbonWidth: CGFloat { + if foldingRibbon.isHidden { 0.0 } else { FoldingRibbonView.width } + } + + /// The gutter's y positions start at the top of the document and increase as it moves down the screen. override public var isFlipped: Bool { true } + /// We override this variable so we can update the ``foldingRibbon``'s frame to match the gutter. + override public var frame: NSRect { + get { + super.frame + } + set { + super.frame = newValue + foldingRibbon.frame = NSRect( + x: newValue.width - edgeInsets.trailing - foldingRibbonWidth, + y: 0.0, + width: foldingRibbonWidth, + height: newValue.height + ) + } + } + public init( font: NSFont, textColor: NSColor, @@ -108,6 +138,8 @@ public class GutterView: NSView { self.textView = textView self.delegate = delegate + foldingRibbon = FoldingRibbonView(textView: textView, levelProvider: nil) + super.init(frame: .zero) clipsToBounds = true wantsLayer = true @@ -115,6 +147,8 @@ public class GutterView: NSView { translatesAutoresizingMaskIntoConstraints = false layer?.masksToBounds = true + addSubview(foldingRibbon) + NotificationCenter.default.addObserver( forName: TextSelectionManager.selectionChangedNotification, object: nil, @@ -124,22 +158,17 @@ public class GutterView: NSView { } } - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - /// Updates the width of the gutter if needed. + /// Updates the width of the gutter if needed to match the maximum line number found as well as the folding ribbon. func updateWidthIfNeeded() { guard let textView else { return } let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: textColor ] - let originalMaxWidth = maxWidth // Reserve at least 3 digits of space no matter what let lineStorageDigits = max(3, String(textView.layoutManager.lineCount).count) @@ -149,27 +178,36 @@ public class GutterView: NSView { NSAttributedString(string: String(repeating: "0", count: lineStorageDigits), attributes: attributes) ) let width = CTLineGetTypographicBounds(maxCtLine, nil, nil, nil) - maxWidth = max(maxWidth, width) + maxLineNumberWidth = max(maxLineNumberWidth, width) maxLineLength = lineStorageDigits } - if originalMaxWidth != maxWidth { - gutterWidth = maxWidth + edgeInsets.horizontal - delegate?.gutterViewWidthDidUpdate(newWidth: maxWidth + edgeInsets.horizontal) + let newWidth = maxLineNumberWidth + edgeInsets.horizontal + foldingRibbonWidth + if frame.size.width != newWidth { + frame.size.width = newWidth + delegate?.gutterViewWidthDidUpdate() } } - private func drawBackground(_ context: CGContext) { + /// Fills the gutter background color. + /// - Parameters: + /// - context: The drawing context to draw in. + /// - dirtyRect: A rect to draw in, received from ``draw(_:)``. + private func drawBackground(_ context: CGContext, dirtyRect: NSRect) { guard let backgroundColor else { return } - let xPos = backgroundEdgeInsets.leading - let width = gutterWidth - backgroundEdgeInsets.trailing + let minX = max(backgroundEdgeInsets.leading, dirtyRect.minX) + let maxX = min(frame.width - backgroundEdgeInsets.trailing, dirtyRect.maxX) + let width = maxX - minX context.saveGState() context.setFillColor(backgroundColor.cgColor) - context.fill(CGRect(x: xPos, y: 0, width: width, height: frame.height)) + context.fill(CGRect(x: minX, y: dirtyRect.minY, width: width, height: dirtyRect.height)) context.restoreGState() } + /// Draws selected line backgrounds from the text view's selection manager into the gutter view, making the + /// selection background appear seamless between the gutter and text view. + /// - Parameter context: The drawing context to use. private func drawSelectedLines(_ context: CGContext) { guard let textView = textView, let selectionManager = textView.selectionManager, @@ -183,7 +221,7 @@ public class GutterView: NSView { context.setFillColor(selectedLineColor.cgColor) let xPos = backgroundEdgeInsets.leading - let width = gutterWidth - backgroundEdgeInsets.trailing + let width = frame.width - backgroundEdgeInsets.trailing for selection in selectionManager.textSelections where selection.range.isEmpty { guard let line = textView.layoutManager.textLineForOffset(selection.range.location), @@ -205,7 +243,11 @@ public class GutterView: NSView { context.restoreGState() } - private func drawLineNumbers(_ context: CGContext) { + /// Draw line numbers in the gutter, limited to a drawing rect. + /// - Parameters: + /// - context: The drawing context to draw in. + /// - dirtyRect: A rect to draw in, received from ``draw(_:)``. + private func drawLineNumbers(_ context: CGContext, dirtyRect: NSRect) { guard let textView = textView else { return } var attributes: [NSAttributedString.Key: Any] = [.font: font] @@ -219,9 +261,10 @@ public class GutterView: NSView { } context.saveGState() + context.clip(to: dirtyRect) context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) - for linePosition in textView.layoutManager.visibleLines() { + for linePosition in textView.layoutManager.linesStartingAt(dirtyRect.minY, until: dirtyRect.maxY) { if selectionRangeMap.intersects(integersIn: linePosition.range) { attributes[.foregroundColor] = selectedLineTextColor ?? textColor } else { @@ -238,7 +281,7 @@ public class GutterView: NSView { let yPos = linePosition.yPos + ascent + (fragment?.heightDifference ?? 0)/2 + fontHeightDifference // Leading padding + (width - linewidth) - let xPos = edgeInsets.leading + (maxWidth - lineNumberWidth) + let xPos = edgeInsets.leading + (maxLineNumberWidth - lineNumberWidth) ContextSetHiddenSmoothingStyle(context, 16) @@ -249,18 +292,20 @@ public class GutterView: NSView { context.restoreGState() } + override public func setNeedsDisplay(_ invalidRect: NSRect) { + updateWidthIfNeeded() + super.setNeedsDisplay(invalidRect) + } + override public func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } - CATransaction.begin() - superview?.clipsToBounds = false - superview?.layer?.masksToBounds = false - updateWidthIfNeeded() - drawBackground(context) + context.saveGState() + drawBackground(context, dirtyRect: dirtyRect) drawSelectedLines(context) - drawLineNumbers(context) - CATransaction.commit() + drawLineNumbers(context, dirtyRect: dirtyRect) + context.restoreGState() } deinit { diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift new file mode 100644 index 000000000..714a48a06 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift @@ -0,0 +1,25 @@ +// +// 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.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift new file mode 100644 index 000000000..3dca3fdec --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -0,0 +1,193 @@ +// +// FoldingRibbonView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/6/25. +// + +import Foundation +import AppKit +import CodeEditTextView + +/// Displays the code folding ribbon in the ``GutterView``. +/// +/// This view draws its contents +class FoldingRibbonView: NSView { + static let width: CGFloat = 7.0 + + private var model: LineFoldingModel + private var hoveringLine: Int? + + @Invalidating(.display) + var backgroundColor: NSColor = NSColor.controlBackgroundColor + + @Invalidating(.display) + var markerColor = CGColor(gray: 0.0, alpha: 0.1) + + override public var isFlipped: Bool { + true + } + + init(textView: TextView, levelProvider: LineFoldProvider?) { + self.model = LineFoldingModel( + textView: textView, + levelProvider: levelProvider + ) + super.init(frame: .zero) + layerContentsRedrawPolicy = .onSetNeedsDisplay + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateTrackingAreas() { + trackingAreas.forEach(removeTrackingArea) + let area = NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .activeInKeyWindow], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + } + + override func mouseMoved(with event: NSEvent) { + let pointInView = convert(event.locationInWindow, from: nil) + hoveringLine = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index + } + + struct FoldMarkerDrawingContext { + let range: ClosedRange + let depth: UInt + let hoveringLine: Int? + + func increment() -> FoldMarkerDrawingContext { + FoldMarkerDrawingContext( + range: range, + depth: depth + 1, + hoveringLine: isHovering() ? nil : hoveringLine + ) + } + + func isHovering() -> Bool { + guard let hoveringLine else { + return false + } + return range.contains(hoveringLine) + } + } + + 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.folds(in: lineRange) + for fold in folds { + drawFoldMarker( + fold, + markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0, hoveringLine: hoveringLine), + 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 + + // TODO: Draw a single line when folds are adjacent + + if markerContext.isHovering() { + // TODO: Handle hover state + } else { + let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) + 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 + ) + } + } + + // Draw subfolds + for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { + drawFoldMarker(subFold, markerContext: markerContext.increment(), in: context, using: layoutManager) + } + } + + /// 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(CGColor(gray: 1.0, alpha: 0.4)) + context.drawPath(using: .eoFill) + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift new file mode 100644 index 000000000..64a15ae71 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift @@ -0,0 +1,13 @@ +// +// 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 new file mode 100644 index 000000000..932728c04 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -0,0 +1,151 @@ +// +// LineFoldingModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView + +/// # 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 levelProvider: LineFoldProvider? + weak var textView: TextView? + + init(textView: TextView, levelProvider: LineFoldProvider?) { + self.textView = textView + self.levelProvider = levelProvider + super.init() + textView.addStorageDelegate(self) + buildFoldsForDocument() + } + + func folds(in lineRange: ClosedRange) -> [FoldRange] { + foldCache.filter({ $0.lineRange.overlaps(lineRange) }) + } + + func buildFoldsForDocument() { + guard let textView, let levelProvider else { return } + foldCache.removeAll(keepingCapacity: true) + + var currentFold: FoldRange? + var currentDepth: Int = 0 + for linePosition in textView.layoutManager.linesInRange(textView.documentRange) { + guard let foldDepth = levelProvider.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 currentDepth == 0 { + foldCache.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 + } + } + + 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) + } +} + +// MARK: - Search Folds + +private extension LineFoldingModel { + /// 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 findCachedFoldAt(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 findCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? { + binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0) + } + + /// 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/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index f4de2e376..0a5a050f3 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -21,7 +21,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy + attachments: [AnyTextAttachment] ) { let maxWidth: CGFloat = if let textView, textView.wrapLines { textView.layoutManager.maxLineLayoutWidth @@ -34,7 +34,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { range: range, stringRef: stringRef, markedRanges: markedRanges, - breakStrategy: breakStrategy + attachments: [] ) // Make all fragments 2px tall @@ -62,6 +62,12 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { // Offset is relative to the whole line, the CTLine is too. - return 8 + (CGFloat(offset - CTLineGetStringRange(lineFragment.ctLine).location) * 1.5) + guard let content = lineFragment.contents.first else { return 0.0 } + switch content.data { + case .text(let ctLine): + return 8 + (CGFloat(offset - CTLineGetStringRange(ctLine).location) * 1.5) + case .attachment: + return 0.0 + } } } diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 1eb96c0c4..fd4360ad3 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -64,7 +64,8 @@ enum Mock { letterSpacing: 1.0, useSystemCursor: false, bracketPairEmphasis: .flash, - showMinimap: true + showMinimap: true, + showFoldingRibbon: true ) } diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index 956a763d9..a1329de4e 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -5,7 +5,7 @@ import AppKit import SwiftUI import TextStory -// swiftlint:disable all +// swiftlint:disable:next type_body_length final class TextViewControllerTests: XCTestCase { var controller: TextViewController! @@ -32,7 +32,8 @@ final class TextViewControllerTests: XCTestCase { letterSpacing: 1.0, useSystemCursor: false, bracketPairEmphasis: .flash, - showMinimap: true + showMinimap: true, + showFoldingRibbon: true ) controller.loadView() @@ -226,24 +227,27 @@ final class TextViewControllerTests: XCTestCase { // Insert lots of spaces controller.indentOption = .spaces(count: 1000) - controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.replaceCharacters( + in: NSRange(location: 0, length: controller.textView.textStorage.length), + with: "" + ) controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) } - func test_letterSpacing() { + func test_letterSpacing() throws { let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) controller.letterSpacing = 1.0 XCTAssertEqual( - controller.attributesFor(nil)[.kern]! as! CGFloat, + try XCTUnwrap(controller.attributesFor(nil)[.kern] as? CGFloat), (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 ) controller.letterSpacing = 2.0 XCTAssertEqual( - controller.attributesFor(nil)[.kern]! as! CGFloat, + try XCTUnwrap(controller.attributesFor(nil)[.kern] as? CGFloat), (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 ) @@ -259,7 +263,7 @@ final class TextViewControllerTests: XCTestCase { controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { @@ -298,7 +302,7 @@ final class TextViewControllerTests: XCTestCase { } func test_findClosingPair() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.textView.string = "{ Lorem Ipsum {} }" var idx: Int? @@ -313,28 +317,40 @@ final class TextViewControllerTests: XCTestCase { // Test extra pair controller.textView.string = "{ Loren Ipsum {}} }" idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) - XCTAssert(idx == 16, "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`") + XCTAssert( + idx == 16, + "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`" + ) // Text extra pair backwards controller.textView.string = "{ Loren Ipsum {{} }" idx = controller.findClosingPair("}", "{", from: 18, limit: 0, reverse: true) - XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") + XCTAssert( + idx == 14, + "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`" + ) // Test missing pair controller.textView.string = "{ Loren Ipsum { }" idx = controller.findClosingPair("{", "}", from: 1, limit: 17, reverse: false) - XCTAssert(idx == nil, "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + XCTAssert( + idx == nil, + "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`" + ) // Test missing pair backwards controller.textView.string = " Loren Ipsum {} }" idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) - XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + XCTAssert( + idx == nil, + "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`" + ) } // MARK: Set Text func test_setText() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.textView.string = "Hello World" controller.textView.selectionManager.setSelectedRange(NSRange(location: 1, length: 2)) @@ -354,7 +370,7 @@ final class TextViewControllerTests: XCTestCase { // MARK: Cursor Positions func test_cursorPositionRangeInit() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.setText("Hello World") // Test adding a position returns a valid one @@ -395,7 +411,7 @@ final class TextViewControllerTests: XCTestCase { } func test_cursorPositionRowColInit() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.setText("Hello World") // Test adding a position returns a valid one @@ -460,5 +476,21 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) } + + // MARK: Folding Ribbon + + func test_foldingRibbonToggle() { + controller.setText("Hello World") + controller.showFoldingRibbon = false + XCTAssertFalse(controller.gutterView.showFoldingRibbon) + controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass + let noRibbonWidth = controller.gutterView.gutterWidth + + controller.showFoldingRibbon = true + XCTAssertTrue(controller.gutterView.showFoldingRibbon) + controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass + XCTAssertEqual(controller.gutterView.gutterWidth, noRibbonWidth + 7.0) + } } -// swiftlint:enable all + +// swiftlint:disable:this file_length From 91ddcecd8ded37a4ac08e1f2cfb7281c7106541c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 10:13:43 -0500 Subject: [PATCH 02/33] Correct Gutter Padding With Transparency, Dark Mode --- Package.resolved | 9 --- .../Gutter/GutterView.swift | 16 +++- .../LineFolding/FoldingRibbonView.swift | 76 +++++++++++++++++-- .../Gutter/LineFolding/LineFoldingModel.swift | 18 +++-- .../LineFoldingModelTests.swift | 55 ++++++++++++++ .../TextViewControllerTests.swift | 4 +- 6 files changed, 149 insertions(+), 29 deletions(-) create mode 100644 Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift diff --git a/Package.resolved b/Package.resolved index b646b2e64..89e82645a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", - "version" : "0.10.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index a8ca75fb2..2a5125789 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -57,6 +57,10 @@ public class GutterView: NSView { @Invalidating(.display) var backgroundEdgeInsets: EdgeInsets = EdgeInsets(leading: 0, trailing: 8) + /// The leading padding for the folding ribbon from the line numbers. + @Invalidating(.display) + var foldingRibbonPadding: CGFloat = 4 + @Invalidating(.display) var backgroundColor: NSColor? = NSColor.controlBackgroundColor @@ -101,7 +105,11 @@ public class GutterView: NSView { /// Syntax helper for determining the required space for the folding ribbon. private var foldingRibbonWidth: CGFloat { - if foldingRibbon.isHidden { 0.0 } else { FoldingRibbonView.width } + if foldingRibbon.isHidden { + 0.0 + } else { + FoldingRibbonView.width + foldingRibbonPadding + } } /// The gutter's y positions start at the top of the document and increase as it moves down the screen. @@ -117,7 +125,7 @@ public class GutterView: NSView { set { super.frame = newValue foldingRibbon.frame = NSRect( - x: newValue.width - edgeInsets.trailing - foldingRibbonWidth, + x: newValue.width - edgeInsets.trailing - foldingRibbonWidth + foldingRibbonPadding, y: 0.0, width: foldingRibbonWidth, height: newValue.height @@ -138,7 +146,7 @@ public class GutterView: NSView { self.textView = textView self.delegate = delegate - foldingRibbon = FoldingRibbonView(textView: textView, levelProvider: nil) + foldingRibbon = FoldingRibbonView(textView: textView, foldProvider: nil) super.init(frame: .zero) clipsToBounds = true @@ -196,7 +204,7 @@ public class GutterView: NSView { private func drawBackground(_ context: CGContext, dirtyRect: NSRect) { guard let backgroundColor else { return } let minX = max(backgroundEdgeInsets.leading, dirtyRect.minX) - let maxX = min(frame.width - backgroundEdgeInsets.trailing, dirtyRect.maxX) + let maxX = min(frame.width - backgroundEdgeInsets.trailing - foldingRibbonWidth, dirtyRect.maxX) let width = maxX - minX context.saveGState() diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index 3dca3fdec..aef151199 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -9,6 +9,47 @@ import Foundation 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 + } + + // if let precedingLinePosition = layoutManager.textLineForIndex(lineNumber - 1), + // let precedingIndentLevel = indentLevelForPosition(precedingLinePosition, textStorage: textStorage) { + // if precedingIndentLevel > indentLevel { + // return precedingIndentLevel + // } + // } + // + // if let nextLinePosition = layoutManager.textLineForIndex(lineNumber + 1), + // let nextIndentLevel = indentLevelForPosition(nextLinePosition, textStorage: textStorage) { + // if nextIndentLevel > indentLevel { + // return nextIndentLevel + // } + // } + + 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 + } +} + +let buh = IndentationLineFoldProvider() + /// Displays the code folding ribbon in the ``GutterView``. /// /// This view draws its contents @@ -22,16 +63,37 @@ class FoldingRibbonView: NSView { var backgroundColor: NSColor = NSColor.controlBackgroundColor @Invalidating(.display) - var markerColor = CGColor(gray: 0.0, alpha: 0.1) + 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.1) + default: + NSColor() + } + }.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 override public var isFlipped: Bool { true } - init(textView: TextView, levelProvider: LineFoldProvider?) { + init(textView: TextView, foldProvider: LineFoldProvider?) { self.model = LineFoldingModel( textView: textView, - levelProvider: levelProvider + foldProvider: buh ) super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay @@ -95,7 +157,7 @@ class FoldingRibbonView: NSView { let lineRange = rangeStart.index...rangeEnd.index context.setFillColor(markerColor) - let folds = model.folds(in: lineRange) + let folds = model.getFolds(in: lineRange) for fold in folds { drawFoldMarker( fold, @@ -107,7 +169,7 @@ class FoldingRibbonView: NSView { context.restoreGState() } - + /// Draw a single fold marker for a fold. /// /// Ensure the correct fill color is set on the drawing context before calling. @@ -158,7 +220,7 @@ class FoldingRibbonView: NSView { drawFoldMarker(subFold, markerContext: markerContext.increment(), in: context, using: layoutManager) } } - + /// 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. @@ -185,7 +247,7 @@ class FoldingRibbonView: NSView { context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) context.addPath(combined) - context.setFillColor(CGColor(gray: 1.0, alpha: 0.4)) + context.setFillColor(markerBorderColor) context.drawPath(using: .eoFill) context.restoreGState() diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift index 932728c04..97bb0a8a9 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -22,35 +22,40 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { /// and ``FoldRange/subFolds``. private var foldCache: [FoldRange] = [] - weak var levelProvider: LineFoldProvider? + weak var foldProvider: LineFoldProvider? weak var textView: TextView? - init(textView: TextView, levelProvider: LineFoldProvider?) { + init(textView: TextView, foldProvider: LineFoldProvider?) { self.textView = textView - self.levelProvider = levelProvider + self.foldProvider = foldProvider super.init() textView.addStorageDelegate(self) buildFoldsForDocument() } - func folds(in lineRange: ClosedRange) -> [FoldRange] { + 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 levelProvider else { return } + 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 = levelProvider.foldLevelAtLine( + guard let foldDepth = foldProvider.foldLevelAtLine( linePosition.index, layoutManager: textView.layoutManager, textStorage: textView.textStorage ) else { continue } + print(foldDepth, linePosition.index) // Start a new fold if foldDepth > currentDepth { let newFold = FoldRange( @@ -59,7 +64,6 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { parent: currentFold, subFolds: [] ) - if currentDepth == 0 { foldCache.append(newFold) } diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift new file mode 100644 index 000000000..4ba76f767 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -0,0 +1,55 @@ +// +// LineFoldingModelTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import Testing +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. + class HillPatternFoldProvider: LineFoldProvider { + func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { + let halfLineCount = (layoutManager.lineCount / 2) - 1 + + return if lineNumber > halfLineCount { + layoutManager.lineCount - 2 - lineNumber + } else { + lineNumber + } + } + } + + let textView: TextView + let model: LineFoldingModel + + init() { + 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 { + let provider = HillPatternFoldProvider() + model.foldProvider = provider + + model.buildFoldsForDocument() + + 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) + } +} diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index a1329de4e..126905be2 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -484,12 +484,12 @@ final class TextViewControllerTests: XCTestCase { controller.showFoldingRibbon = false XCTAssertFalse(controller.gutterView.showFoldingRibbon) controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass - let noRibbonWidth = controller.gutterView.gutterWidth + let noRibbonWidth = controller.gutterView.frame.width controller.showFoldingRibbon = true XCTAssertTrue(controller.gutterView.showFoldingRibbon) controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass - XCTAssertEqual(controller.gutterView.gutterWidth, noRibbonWidth + 7.0) + XCTAssertEqual(controller.gutterView.frame.width, noRibbonWidth + 7.0) } } From 0a7519e3abd219dc25092408172b8764587de83e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 10:14:08 -0500 Subject: [PATCH 03/33] Revert Debugging Changes --- .../LineFolding/FoldingRibbonView.swift | 43 +------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index aef151199..77d9b6263 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -9,47 +9,6 @@ import Foundation 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 - } - - // if let precedingLinePosition = layoutManager.textLineForIndex(lineNumber - 1), - // let precedingIndentLevel = indentLevelForPosition(precedingLinePosition, textStorage: textStorage) { - // if precedingIndentLevel > indentLevel { - // return precedingIndentLevel - // } - // } - // - // if let nextLinePosition = layoutManager.textLineForIndex(lineNumber + 1), - // let nextIndentLevel = indentLevelForPosition(nextLinePosition, textStorage: textStorage) { - // if nextIndentLevel > indentLevel { - // return nextIndentLevel - // } - // } - - 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 - } -} - -let buh = IndentationLineFoldProvider() - /// Displays the code folding ribbon in the ``GutterView``. /// /// This view draws its contents @@ -93,7 +52,7 @@ class FoldingRibbonView: NSView { init(textView: TextView, foldProvider: LineFoldProvider?) { self.model = LineFoldingModel( textView: textView, - foldProvider: buh + foldProvider: foldProvider ) super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay From c056e4a181ceb4a297805e600a781476727d8ed1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 10:15:59 -0500 Subject: [PATCH 04/33] Add Demo Line Fold Provider (For Testing) --- .../IndentationLineFoldProvider.swift | 34 +++++++++++++++++++ .../LineFolding/FoldingRibbonView.swift | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift new file mode 100644 index 000000000..03dbd9fa1 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift @@ -0,0 +1,34 @@ +// +// 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/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index 77d9b6263..413851fb8 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -9,6 +9,9 @@ import Foundation import AppKit import CodeEditTextView +#warning("Replace before release") +fileprivate let demoFoldProvider = IndentationLineFoldProvider() + /// Displays the code folding ribbon in the ``GutterView``. /// /// This view draws its contents @@ -50,9 +53,10 @@ class FoldingRibbonView: NSView { } init(textView: TextView, foldProvider: LineFoldProvider?) { + #warning("Replace before release") self.model = LineFoldingModel( textView: textView, - foldProvider: foldProvider + foldProvider: foldProvider ?? demoFoldProvider ) super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay From 0b840f6c0e58992a43be855ecc42595b21fbba9c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 10:29:45 -0500 Subject: [PATCH 05/33] Remove Some Unnecessary Hovering Stuff --- .../LineFolding/FoldingRibbonView.swift | 24 +++++++------------ .../Gutter/LineFolding/LineFoldingModel.swift | 8 +++---- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index 413851fb8..d7d8543bf 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -82,25 +82,18 @@ class FoldingRibbonView: NSView { 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 - let hoveringLine: Int? - func increment() -> FoldMarkerDrawingContext { + /// Increment the depth + func incrementDepth() -> FoldMarkerDrawingContext { FoldMarkerDrawingContext( range: range, - depth: depth + 1, - hoveringLine: isHovering() ? nil : hoveringLine + depth: depth + 1 ) } - - func isHovering() -> Bool { - guard let hoveringLine else { - return false - } - return range.contains(hoveringLine) - } } override func draw(_ dirtyRect: NSRect) { @@ -124,7 +117,7 @@ class FoldingRibbonView: NSView { for fold in folds { drawFoldMarker( fold, - markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0, hoveringLine: hoveringLine), + markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0), in: context, using: layoutManager ) @@ -156,12 +149,11 @@ class FoldingRibbonView: NSView { let maxYPosition = maxPosition.yPos + maxPosition.height - // TODO: Draw a single line when folds are adjacent - - if markerContext.isHovering() { + 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) @@ -180,7 +172,7 @@ class FoldingRibbonView: NSView { // Draw subfolds for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { - drawFoldMarker(subFold, markerContext: markerContext.increment(), in: context, using: layoutManager) + drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) } } diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift index 97bb0a8a9..b2e4dfbcf 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -104,18 +104,18 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { } invalidateLine(lineNumber: lineNumber) } -} - -// MARK: - Search Folds -private extension LineFoldingModel { /// 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 findCachedFoldAt(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. From 5c813dd364bcd510145736087e13b21f16b91b04 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 12:53:47 -0500 Subject: [PATCH 06/33] Implement Hover State and Animation --- .../LineFolding/FoldingRibbonView+Draw.swift | 191 +++++++++++++++++ .../LineFolding/FoldingRibbonView.swift | 199 ++++++++---------- .../Gutter/LineFolding/LineFoldingModel.swift | 15 +- 3 files changed, 281 insertions(+), 124 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift new file mode 100644 index 000000000..5d68da778 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift @@ -0,0 +1,191 @@ +// +// FoldingRibbonView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +extension FoldingRibbonView { + 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..8df0cb63d 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -16,10 +16,21 @@ fileprivate let demoFoldProvider = IndentationLineFoldProvider() /// /// 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 +41,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 +59,30 @@ 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 + override public var isFlipped: Bool { true } @@ -60,17 +95,20 @@ class FoldingRibbonView: NSView { ) super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay + clipsToBounds = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + // 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,7 +117,46 @@ class FoldingRibbonView: NSView { override func mouseMoved(with event: NSEvent) { let pointInView = convert(event.locationInWindow, from: nil) - hoveringLine = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index + guard let lineNumber = model.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 { + return + } + 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 + } + + // Don't animate these + hoverAnimationProgress = 1.0 + hoveringFold = newHoverRange + } + + override func mouseExited(with event: NSEvent) { + hoverAnimationProgress = 0.0 + hoveringFold = nil } /// The context in which the fold is being drawn, including the depth and fold range. @@ -95,116 +172,4 @@ class FoldingRibbonView: NSView { ) } } - - 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 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 - ) - } - } - - // Draw subfolds - for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { - drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) - } - } - - /// 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/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift index b2e4dfbcf..9785afbce 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -55,7 +55,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { ) else { continue } - print(foldDepth, linePosition.index) + // Start a new fold if foldDepth > currentDepth { let newFold = FoldRange( @@ -109,19 +109,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. From 8ee94b5237cdcc754d16822e9ba8728b715b6424 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 12:55:00 -0500 Subject: [PATCH 07/33] Move DrawingContext Struct --- .../LineFolding/FoldingRibbonView+Draw.swift | 14 ++++++++++++++ .../Gutter/LineFolding/FoldingRibbonView.swift | 14 -------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift index 5d68da778..d85a25916 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift @@ -9,6 +9,20 @@ 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 { diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index 8df0cb63d..4800e8115 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -158,18 +158,4 @@ class FoldingRibbonView: NSView { hoverAnimationProgress = 0.0 hoveringFold = nil } - - /// 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 - ) - } - } } From 4d9d1d0b0ef0e6b31bdd25861e03449e1939900d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 13:28:04 -0500 Subject: [PATCH 08/33] Update View When Folds Change --- .../Gutter/LineFolding/FoldingRibbonView.swift | 11 +++++++++++ .../Gutter/LineFolding/LineFoldingModel.swift | 15 +++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index 4800e8115..6fe1f9907 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -8,6 +8,7 @@ import Foundation import AppKit import CodeEditTextView +import Combine #warning("Replace before release") fileprivate let demoFoldProvider = IndentationLineFoldProvider() @@ -83,6 +84,8 @@ class FoldingRibbonView: NSView { } }.cgColor + private var foldUpdateCancellable: AnyCancellable? + override public var isFlipped: Bool { true } @@ -96,12 +99,20 @@ 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() { diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift index 9785afbce..f55007a34 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 @@ -64,25 +67,25 @@ 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) { From 2c1af464f76aa80c366a24ad0ad8a069e05693a0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 16:12:46 -0500 Subject: [PATCH 09/33] Dispatch Folding Calculation To Background --- .../DispatchQueue+dispatchMainIfNot.swift | 6 +- .../IndentationLineFoldProvider.swift | 27 +-- .../LineFolding/FoldingRibbonView.swift | 4 +- .../Gutter/LineFolding/LineFoldProvider.swift | 2 +- .../Gutter/LineFolding/LineFoldingModel.swift | 186 +++++++++++------- 5 files changed, 126 insertions(+), 99 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift index 0b14ea65a..322a8d203 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 syncMainIfNot(_ item: () -> T) -> T { if Thread.isMainThread { return item() } else { - return DispatchQueue.main.sync { - return item() - } + return DispatchQueue.main.sync(execute: item) } } } diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift index 03dbd9fa1..6a03a4be3 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift @@ -9,26 +9,13 @@ 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 + func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int? { + for idx in 0...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 + return nil } } diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index 6fe1f9907..05f9c1e5a 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -11,7 +11,7 @@ import CodeEditTextView import Combine #warning("Replace before release") -fileprivate let demoFoldProvider = IndentationLineFoldProvider() +private let demoFoldProvider = IndentationLineFoldProvider() /// Displays the code folding ribbon in the ``GutterView``. /// @@ -100,7 +100,7 @@ class FoldingRibbonView: NSView { layerContentsRedrawPolicy = .onSetNeedsDisplay clipsToBounds = false - foldUpdateCancellable = model.foldsUpdatedPublisher.sink { + foldUpdateCancellable = model.$foldCache.receive(on: RunLoop.main).sink { _ in self.needsDisplay = true } } diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift index 64a15ae71..97b79f4ca 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift @@ -9,5 +9,5 @@ import AppKit import CodeEditTextView protocol LineFoldProvider: AnyObject { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? + func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int? } diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift index f55007a34..a63146e56 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -9,6 +9,99 @@ import AppKit import CodeEditTextView import Combine +class LineFoldCalculator { + weak var foldProvider: LineFoldProvider? + weak var textView: TextView? + + var rangesPublisher = CurrentValueSubject<[FoldRange], Never>([]) + + private let workQueue = DispatchQueue(label: "app.codeedit.line-folds") + + var textChangedReceiver = PassthroughSubject() + private var textChangedCancellable: AnyCancellable? + + init(foldProvider: LineFoldProvider?, textView: TextView) { + self.foldProvider = foldProvider + self.textView = textView + + textChangedCancellable = textChangedReceiver.throttle(for: 0.1, scheduler: RunLoop.main, latest: true).sink { + self.buildFoldsForDocument() + } + } + + /// 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() { + workQueue.async { + guard let textView = self.textView, let foldProvider = self.foldProvider else { return } + var foldCache: [FoldRange] = [] + var currentFold: FoldRange? + var currentDepth: Int = 0 + var iterator = textView.layoutManager.linesInRange(textView.documentRange) + + var lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) + while !lines.isEmpty { + for (lineNumber, foldDepth) in lines { + // Start a new fold + if foldDepth > currentDepth { + let newFold = FoldRange( + lineRange: (lineNumber - 1)...(lineNumber - 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...lineNumber + } + currentFold = currentFold?.parent + } + + currentDepth = foldDepth + } + lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) + } + + self.rangesPublisher.send(foldCache) + } + } + + private func getMoreLines( + textView: TextView, + iterator: inout TextLayoutManager.RangeIterator, + foldProvider: LineFoldProvider + ) -> [(index: Int, foldDepth: Int)] { + DispatchQueue.main.asyncAndWait { + var results: [(index: Int, foldDepth: Int)] = [] + var count = 0 + while count < 50, let linePosition = iterator.next() { + guard let substring = textView.textStorage.substring(from: linePosition.range) as NSString?, + let foldDepth = foldProvider.foldLevelAtLine( + linePosition.index, + substring: substring + ) else { + count += 1 + continue + } + + results.append((linePosition.index, foldDepth)) + count += 1 + } + return results + } + } +} + /// # Basic Premise /// /// We need to update, delete, or add fold ranges in the invalidated lines. @@ -21,91 +114,35 @@ import Combine 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] = [] + @Published var foldCache: [FoldRange] = [] + private var calculator: LineFoldCalculator + private var cancellable: AnyCancellable? - weak var foldProvider: LineFoldProvider? weak var textView: TextView? - lazy var foldsUpdatedPublisher = PassthroughSubject() - init(textView: TextView, foldProvider: LineFoldProvider?) { self.textView = textView - self.foldProvider = foldProvider + self.calculator = LineFoldCalculator(foldProvider: foldProvider, textView: textView) super.init() textView.addStorageDelegate(self) - buildFoldsForDocument() + cancellable = self.calculator.rangesPublisher.receive(on: RunLoop.main).assign(to: \.foldCache, on: self) + calculator.textChangedReceiver.send() } 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 { + guard editedMask.contains(.editedCharacters) else { return } - invalidateLine(lineNumber: lineNumber) + calculator.textChangedReceiver.send() } /// Finds the deepest cached depth of the fold for a line number. @@ -119,20 +156,20 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { /// - 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) + binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0, findDeepest: true) } } // 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 + currentDepth: Int, + findDeepest: Bool ) -> (range: FoldRange, depth: Int)? { var low = 0 var high = folds.count - 1 @@ -143,11 +180,16 @@ private extension LineFoldingModel { if fold.lineRange.contains(lineNumber) { // Search deeper into subFolds, if any - return binarySearchFoldsArray( - lineNumber: lineNumber, - folds: fold.subFolds, - currentDepth: currentDepth + 1 - ) ?? (fold, currentDepth) + if findDeepest { + return binarySearchFoldsArray( + lineNumber: lineNumber, + folds: fold.subFolds, + currentDepth: currentDepth + 1, + findDeepest: findDeepest + ) ?? (fold, currentDepth) + } else { + return (fold, currentDepth) + } } else if lineNumber < fold.lineRange.lowerBound { high = mid - 1 } else { From 2f1fdad8bbb94d0ac21322270e7d200bc01edcda Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 9 May 2025 14:06:03 -0500 Subject: [PATCH 10/33] Use Lock For Cache, Skip Depth Changes --- .../DispatchQueue+dispatchMainIfNot.swift | 4 +- .../TextView+/TextView+createReadBlock.swift | 4 +- .../Gutter/LineFolding/LineFoldingModel.swift | 201 ------------------ .../IndentationLineFoldProvider.swift | 0 .../FoldProviders}/LineFoldProvider.swift | 0 .../Model}/FoldRange.swift | 4 +- .../Model/LineFoldCalculator.swift | 122 +++++++++++ .../LineFolding/Model/LineFoldingModel.swift | 113 ++++++++++ .../View}/FoldingRibbonView+Draw.swift | 0 .../View}/FoldingRibbonView.swift | 0 .../TreeSitter/Atomic.swift | 4 + .../TreeSitter/LanguageLayer.swift | 2 +- 12 files changed, 247 insertions(+), 207 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift rename Sources/CodeEditSourceEditor/{Gutter/LineFolding/DefaultProviders => LineFolding/FoldProviders}/IndentationLineFoldProvider.swift (100%) rename Sources/CodeEditSourceEditor/{Gutter/LineFolding => LineFolding/FoldProviders}/LineFoldProvider.swift (100%) rename Sources/CodeEditSourceEditor/{Gutter/LineFolding => LineFolding/Model}/FoldRange.swift (75%) create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift rename Sources/CodeEditSourceEditor/{Gutter/LineFolding => LineFolding/View}/FoldingRibbonView+Draw.swift (100%) rename Sources/CodeEditSourceEditor/{Gutter/LineFolding => LineFolding/View}/FoldingRibbonView.swift (100%) diff --git a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift index 322a8d203..b579e8d52 100644 --- a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift +++ b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift @@ -27,11 +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: () -> T) -> T { + static func waitMainIfNot(_ item: () -> T) -> T { if Thread.isMainThread { return item() } else { - return DispatchQueue.main.sync(execute: item) + return DispatchQueue.main.asyncAndWait(execute: item) } } } 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/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift deleted file mode 100644 index a63146e56..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// LineFoldingModel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import AppKit -import CodeEditTextView -import Combine - -class LineFoldCalculator { - weak var foldProvider: LineFoldProvider? - weak var textView: TextView? - - var rangesPublisher = CurrentValueSubject<[FoldRange], Never>([]) - - private let workQueue = DispatchQueue(label: "app.codeedit.line-folds") - - var textChangedReceiver = PassthroughSubject() - private var textChangedCancellable: AnyCancellable? - - init(foldProvider: LineFoldProvider?, textView: TextView) { - self.foldProvider = foldProvider - self.textView = textView - - textChangedCancellable = textChangedReceiver.throttle(for: 0.1, scheduler: RunLoop.main, latest: true).sink { - self.buildFoldsForDocument() - } - } - - /// 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() { - workQueue.async { - guard let textView = self.textView, let foldProvider = self.foldProvider else { return } - var foldCache: [FoldRange] = [] - var currentFold: FoldRange? - var currentDepth: Int = 0 - var iterator = textView.layoutManager.linesInRange(textView.documentRange) - - var lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) - while !lines.isEmpty { - for (lineNumber, foldDepth) in lines { - // Start a new fold - if foldDepth > currentDepth { - let newFold = FoldRange( - lineRange: (lineNumber - 1)...(lineNumber - 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...lineNumber - } - currentFold = currentFold?.parent - } - - currentDepth = foldDepth - } - lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) - } - - self.rangesPublisher.send(foldCache) - } - } - - private func getMoreLines( - textView: TextView, - iterator: inout TextLayoutManager.RangeIterator, - foldProvider: LineFoldProvider - ) -> [(index: Int, foldDepth: Int)] { - DispatchQueue.main.asyncAndWait { - var results: [(index: Int, foldDepth: Int)] = [] - var count = 0 - while count < 50, let linePosition = iterator.next() { - guard let substring = textView.textStorage.substring(from: linePosition.range) as NSString?, - let foldDepth = foldProvider.foldLevelAtLine( - linePosition.index, - substring: substring - ) else { - count += 1 - continue - } - - results.append((linePosition.index, foldDepth)) - count += 1 - } - return results - } - } -} - -/// # 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``. - @Published var foldCache: [FoldRange] = [] - private var calculator: LineFoldCalculator - private var cancellable: AnyCancellable? - - weak var textView: TextView? - - init(textView: TextView, foldProvider: LineFoldProvider?) { - self.textView = textView - self.calculator = LineFoldCalculator(foldProvider: foldProvider, textView: textView) - super.init() - textView.addStorageDelegate(self) - cancellable = self.calculator.rangesPublisher.receive(on: RunLoop.main).assign(to: \.foldCache, on: self) - calculator.textChangedReceiver.send() - } - - func getFolds(in lineRange: ClosedRange) -> [FoldRange] { - foldCache.filter({ $0.lineRange.overlaps(lineRange) }) - } - - func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - guard editedMask.contains(.editedCharacters) else { - return - } - calculator.textChangedReceiver.send() - } - - /// 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, findDeepest: true) - } -} - -// 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, - findDeepest: Bool - ) -> (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 - if findDeepest { - return binarySearchFoldsArray( - lineNumber: lineNumber, - folds: fold.subFolds, - currentDepth: currentDepth + 1, - findDeepest: findDeepest - ) ?? (fold, currentDepth) - } else { - return (fold, currentDepth) - } - } else if lineNumber < fold.lineRange.lowerBound { - high = mid - 1 - } else { - low = mid + 1 - } - } - return nil - } -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift rename to Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift rename to Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift similarity index 75% rename from Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift rename to Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift index 714a48a06..d84929d4c 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift @@ -11,14 +11,16 @@ import Foundation class FoldRange { var lineRange: ClosedRange var range: NSRange + var depth: Int /// 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]) { + init(lineRange: ClosedRange, range: NSRange, depth: Int, parent: FoldRange?, subFolds: [FoldRange]) { self.lineRange = lineRange self.range = range + self.depth = depth self.subFolds = subFolds self.parent = parent } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift new file mode 100644 index 000000000..40ec540fe --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -0,0 +1,122 @@ +// +// LineFoldCalculator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView +import Combine + +/// A utility that calculates foldable line ranges in a text document based on indentation depth. +/// +/// `LineFoldCalculator` observes text edits and rebuilds fold regions asynchronously. +/// Fold information is emitted via `rangesPublisher`. +/// Notify the calculator it should re-calculate +class LineFoldCalculator { + weak var foldProvider: LineFoldProvider? + weak var textView: TextView? + + var rangesPublisher = CurrentValueSubject<[FoldRange], Never>([]) + + private let workQueue = DispatchQueue.global(qos: .default) + + var textChangedReceiver = PassthroughSubject<(NSRange, Int), Never>() + private var textChangedCancellable: AnyCancellable? + + init(foldProvider: LineFoldProvider?, textView: TextView) { + self.foldProvider = foldProvider + self.textView = textView + + textChangedCancellable = textChangedReceiver + .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) + .sink { edit in + self.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) { + workQueue.async { + guard let textView = self.textView, let foldProvider = self.foldProvider else { return } + var foldCache: [FoldRange] = [] + var currentFold: FoldRange? + var currentDepth: Int = 0 + var iterator = textView.layoutManager.linesInRange(textView.documentRange) + + var lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) + while let lineChunk = lines { + for (lineNumber, foldDepth) in lineChunk { + // Start a new fold, going deeper to a new depth. + if foldDepth > currentDepth { + let newFold = FoldRange( + lineRange: (lineNumber - 1)...(lineNumber - 1), + range: .zero, + depth: foldDepth, + parent: currentFold, + subFolds: [] + ) + + if currentFold == nil { + foldCache.append(newFold) + } else { + currentFold?.subFolds.append(newFold) + } + currentFold = newFold + } else if foldDepth < currentDepth { + // End this fold, go shallower "popping" folds deeper than the new depth + while let fold = currentFold, fold.depth > foldDepth { + // close this fold at the current line + fold.lineRange = fold.lineRange.lowerBound...lineNumber + // move up + currentFold = fold.parent + } + } + + currentDepth = foldDepth + } + lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) + } + + // Clean up any hanging folds. + while let fold = currentFold { + fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount + currentFold = fold.parent + } + + self.rangesPublisher.send(foldCache) + } + } + + private func getMoreLines( + textView: TextView, + iterator: inout TextLayoutManager.RangeIterator, + foldProvider: LineFoldProvider + ) -> [(index: Int, foldDepth: Int)]? { + DispatchQueue.main.asyncAndWait { + var results: [(index: Int, foldDepth: Int)] = [] + var count = 0 + while count < 50, let linePosition = iterator.next() { + guard let substring = textView.textStorage.substring(from: linePosition.range) as NSString?, + let foldDepth = foldProvider.foldLevelAtLine( + linePosition.index, + substring: substring + ) else { + count += 1 + continue + } + + results.append((linePosition.index, foldDepth)) + count += 1 + } + if results.isEmpty && count == 0 { + return nil + } + return results + } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift new file mode 100644 index 000000000..fc6fd7377 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -0,0 +1,113 @@ +// +// 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``. + @Published var foldCache: Atomic<[FoldRange]> = Atomic([]) + private var cacheLock = NSLock() + private var calculator: LineFoldCalculator + private var cancellable: AnyCancellable? + + weak var textView: TextView? + + init(textView: TextView, foldProvider: LineFoldProvider?) { + self.textView = textView + self.calculator = LineFoldCalculator(foldProvider: foldProvider, textView: textView) + super.init() + textView.addStorageDelegate(self) + cancellable = self.calculator.rangesPublisher.receive(on: RunLoop.main).sink { newFolds in + self.foldCache.mutate { $0 = newFolds } + } + calculator.textChangedReceiver.send((.zero, 0)) + } + + func getFolds(in lineRange: ClosedRange) -> [FoldRange] { + foldCache.withValue { $0.filter({ $0.lineRange.overlaps(lineRange) }) } + } + + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { + return + } + calculator.textChangedReceiver.send((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) -> (range: FoldRange, depth: Int)? { + foldCache.withValue { foldCache in + binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0, findDeepest: true) + } + } +} + +// 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, + findDeepest: Bool + ) -> (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 + if findDeepest { + return binarySearchFoldsArray( + lineNumber: lineNumber, + folds: fold.subFolds, + currentDepth: currentDepth + 1, + findDeepest: findDeepest + ) ?? (fold, currentDepth) + } else { + return (fold, currentDepth) + } + } else if lineNumber < fold.lineRange.lowerBound { + high = mid - 1 + } else { + low = mid + 1 + } + } + return nil + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift rename to Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift rename to Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift 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 { From f8433f37cd459ba06f28ea094aa60ee34fe80c35 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 9 May 2025 14:07:30 -0500 Subject: [PATCH 11/33] Sanity Check Range --- .../LineFolding/Model/LineFoldCalculator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 40ec540fe..d6a2931ae 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -101,7 +101,8 @@ class LineFoldCalculator { var results: [(index: Int, foldDepth: Int)] = [] var count = 0 while count < 50, let linePosition = iterator.next() { - guard let substring = textView.textStorage.substring(from: linePosition.range) as NSString?, + guard textView.textStorage.length <= linePosition.range.max, + let substring = textView.textStorage.substring(from: linePosition.range) as NSString?, let foldDepth = foldProvider.foldLevelAtLine( linePosition.index, substring: substring From d618acee82091f1c748dcb433ab2f014b449f84d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 9 May 2025 16:25:22 -0500 Subject: [PATCH 12/33] Toggle Folding State --- .../TextViewController+Lifecycle.swift | 1 + .../Gutter/GutterView.swift | 2 +- .../IndentationLineFoldProvider.swift | 32 +++++++- .../FoldProviders/LineFoldProvider.swift | 30 +++++++- .../LineFolding/Model/FoldRange.swift | 11 ++- .../Model/LineFoldCalculator.swift | 73 +++++++++++++------ .../Placeholder/LineFoldPlaceholder.swift | 27 +++++++ .../View/FoldingRibbonView+Draw.swift | 37 +++++++++- .../LineFolding/View/FoldingRibbonView.swift | 29 ++++++++ 9 files changed, 216 insertions(+), 26 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 86384a6b2..323c7e3ef 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -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/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 2a5125789..4bdf0a2e7 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 { diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift index 6a03a4be3..9cd02bc5b 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift @@ -9,7 +9,7 @@ import AppKit import CodeEditTextView final class IndentationLineFoldProvider: LineFoldProvider { - func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int? { + func indentLevelAtLine(substring: NSString) -> Int? { for idx in 0.. LineFoldProviderLineInfo? { + guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesWithoutNewlines)?.length, + leadingIndent > 0 else { + return nil + } + + if leadingIndent < currentDepth { + // End the fold at the start of whitespace + return .endFold(rangeEnd: lineRange.location + leadingIndent, newDepth: leadingIndent) + } + + // 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 nil + } + + if nextIndent > currentDepth, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) { + return .startFold(rangeStart: trailingWhitespace.location, newDepth: nextIndent) + } + + return nil + } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift index 97b79f4ca..c155d4de0 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift @@ -8,6 +8,34 @@ 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 + } + } +} + protocol LineFoldProvider: AnyObject { - func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int? + func foldLevelAtLine( + lineNumber: Int, + lineRange: NSRange, + currentDepth: Int, + text: NSTextStorage + ) -> LineFoldProviderLineInfo? } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift index d84929d4c..f775ae5b3 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift @@ -12,15 +12,24 @@ class FoldRange { var lineRange: ClosedRange var range: NSRange var depth: Int + var collapsed: Bool /// Ordered array of ranges that are nested in this fold. var subFolds: [FoldRange] weak var parent: FoldRange? - init(lineRange: ClosedRange, range: NSRange, depth: Int, parent: FoldRange?, subFolds: [FoldRange]) { + init( + lineRange: ClosedRange, + range: NSRange, + depth: Int, + collapsed: Bool, + parent: FoldRange?, + subFolds: [FoldRange] + ) { self.lineRange = lineRange self.range = range self.depth = depth + self.collapsed = collapsed self.subFolds = subFolds self.parent = parent } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index d6a2931ae..0ed289377 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -15,6 +15,12 @@ import Combine /// Fold information is emitted via `rangesPublisher`. /// Notify the calculator it should re-calculate class LineFoldCalculator { + private struct LineInfo { + let lineNumber: Int + let providerInfo: LineFoldProviderLineInfo + let collapsed: Bool + } + weak var foldProvider: LineFoldProvider? weak var textView: TextView? @@ -48,15 +54,21 @@ class LineFoldCalculator { var currentDepth: Int = 0 var iterator = textView.layoutManager.linesInRange(textView.documentRange) - var lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) + var lines = self.getMoreLines( + textView: textView, + iterator: &iterator, + lastDepth: currentDepth, + foldProvider: foldProvider + ) while let lineChunk = lines { - for (lineNumber, foldDepth) in lineChunk { + for lineInfo in lineChunk { // Start a new fold, going deeper to a new depth. - if foldDepth > currentDepth { + if lineInfo.providerInfo.depth > currentDepth { let newFold = FoldRange( - lineRange: (lineNumber - 1)...(lineNumber - 1), - range: .zero, - depth: foldDepth, + lineRange: lineInfo.lineNumber...lineInfo.lineNumber, + range: NSRange(location: lineInfo.providerInfo.rangeIndice, length: 0), + depth: lineInfo.providerInfo.depth, + collapsed: lineInfo.collapsed, parent: currentFold, subFolds: [] ) @@ -67,24 +79,31 @@ class LineFoldCalculator { currentFold?.subFolds.append(newFold) } currentFold = newFold - } else if foldDepth < currentDepth { + } else if lineInfo.providerInfo.depth < currentDepth { // End this fold, go shallower "popping" folds deeper than the new depth - while let fold = currentFold, fold.depth > foldDepth { + while let fold = currentFold, fold.depth > lineInfo.providerInfo.depth { // close this fold at the current line - fold.lineRange = fold.lineRange.lowerBound...lineNumber + fold.lineRange = fold.lineRange.lowerBound...lineInfo.lineNumber + fold.range = NSRange(start: fold.range.location, end: lineInfo.providerInfo.rangeIndice) // move up currentFold = fold.parent } } - currentDepth = foldDepth + currentDepth = lineInfo.providerInfo.depth } - lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider) + lines = self.getMoreLines( + textView: textView, + iterator: &iterator, + lastDepth: currentDepth, + foldProvider: foldProvider + ) } // Clean up any hanging folds. while let fold = currentFold { - fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount + fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount - 1 + fold.range = NSRange(start: fold.range.location, end: textView.documentRange.length) currentFold = fold.parent } @@ -95,24 +114,36 @@ class LineFoldCalculator { private func getMoreLines( textView: TextView, iterator: inout TextLayoutManager.RangeIterator, + lastDepth: Int, foldProvider: LineFoldProvider - ) -> [(index: Int, foldDepth: Int)]? { + ) -> [LineInfo]? { DispatchQueue.main.asyncAndWait { - var results: [(index: Int, foldDepth: Int)] = [] + var results: [LineInfo] = [] var count = 0 + var lastDepth = lastDepth while count < 50, let linePosition = iterator.next() { - guard textView.textStorage.length <= linePosition.range.max, - let substring = textView.textStorage.substring(from: linePosition.range) as NSString?, - let foldDepth = foldProvider.foldLevelAtLine( - linePosition.index, - substring: substring - ) else { + guard let foldInfo = foldProvider.foldLevelAtLine( + lineNumber: linePosition.index, + lineRange: linePosition.range, + currentDepth: lastDepth, + text: textView.textStorage + ) else { count += 1 continue } + let attachments = textView.layoutManager.attachments + .getAttachmentsOverlapping(linePosition.range) + .compactMap({ $0.attachment as? LineFoldPlaceholder }) - results.append((linePosition.index, foldDepth)) + results.append( + LineInfo( + lineNumber: linePosition.index, + providerInfo: foldInfo, + collapsed: !attachments.isEmpty + ) + ) count += 1 + lastDepth = foldInfo.depth } if results.isEmpty && count == 0 { return nil diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift new file mode 100644 index 000000000..d973a6483 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -0,0 +1,27 @@ +// +// LineFoldPlaceholder.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView + +class LineFoldPlaceholder: TextAttachment { + 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 index d85a25916..c81ab8f74 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -76,7 +76,9 @@ extension FoldingRibbonView { let maxYPosition = maxPosition.yPos + maxPosition.height - if let hoveringFold, + if fold.collapsed { + drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context) + } else if let hoveringFold, hoveringFold.depth == markerContext.depth, fold.lineRange == hoveringFold.range { drawHoveredFold( @@ -100,6 +102,39 @@ extension FoldingRibbonView { } } + private func drawCollapsedFold( + minYPosition: CGFloat, + maxYPosition: CGFloat, + in context: CGContext + ) { + context.saveGState() + + let fillRect = CGRect(x: 0, y: minYPosition, width: Self.width, height: maxYPosition - minYPosition) + + let height = 5.0 + let minX = 2.0 + let maxX = Self.width - 2.0 + let centerY = minYPosition + (maxYPosition - minYPosition)/2 + let minY = centerY - (height/2) + let maxY = centerY + (height/2) + let chevron = CGMutablePath() + + chevron.move(to: CGPoint(x: minX, y: minY)) + chevron.addLine(to: CGPoint(x: maxX, y: centerY)) + chevron.addLine(to: CGPoint(x: minX, y: maxY)) + + context.setStrokeColor(NSColor.secondaryLabelColor.cgColor) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setLineWidth(1.3) + + context.fill(fillRect) + context.addPath(chevron) + context.strokePath() + + context.restoreGState() + } + private func drawHoveredFold( markerContext: FoldMarkerDrawingContext, minYPosition: CGFloat, diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index 05f9c1e5a..1a3505d79 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -113,6 +113,11 @@ class FoldingRibbonView: NSView { foldUpdateCancellable?.cancel() } + override public func resetCursorRects() { + // Don't use an iBeam in this view + addCursorRect(bounds, cursor: .arrow) + } + // MARK: - Hover override func updateTrackingAreas() { @@ -126,6 +131,30 @@ class FoldingRibbonView: NSView { addTrackingArea(area) } + var attachments: [LineFoldPlaceholder] = [] + + override func mouseDown(with event: NSEvent) { + let clickPoint = convert(event.locationInWindow, from: nil) + guard event.type == .leftMouseDown, + let lineNumber = model.textView?.layoutManager.textLineForPosition(clickPoint.y)?.index, + let fold = model.getCachedFoldAt(lineNumber: lineNumber) else { + super.mouseDown(with: event) + return + } + if let attachment = model.textView?.layoutManager.attachments.getAttachmentsStartingIn(fold.range.range).first { + model.textView?.layoutManager.attachments.remove(atOffset: attachment.range.location) + fold.range.collapsed = false + attachments.removeAll(where: { $0 === attachment.attachment }) + } else { + let placeholder = LineFoldPlaceholder() + model.textView?.layoutManager.attachments.add(placeholder, for: fold.range.range) + attachments.append(placeholder) + fold.range.collapsed = true + } + + model.textView?.needsLayout = true + } + override func mouseMoved(with event: NSEvent) { let pointInView = convert(event.locationInWindow, from: nil) guard let lineNumber = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index, From d3c03cfe16b29b13f246dd08ab926296014a677f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 28 May 2025 16:12:23 -0500 Subject: [PATCH 13/33] Make StyledRangeStore Generalized and `Sendable` --- .../Highlighting/Highlighter.swift | 2 +- .../StyledRangeContainer.swift | 51 +++++++--- .../StyledRangeStoreRun.swift | 47 --------- .../StyledRangeStore+Coalesce.swift | 6 +- .../StyledRangeStore+FindIndex.swift | 0 .../StyledRangeStore+OffsetMetric.swift | 0 .../StyledRangeStore+StyledRun.swift | 19 ++-- .../StyledRangeStore/StyledRangeStore.swift | 28 +++--- .../StyledRangeStoreRun.swift | 48 +++++++++ .../StyledRangeContainerTests.swift | 44 ++++----- .../Highlighting/StyledRangeStoreTests.swift | 97 ++++++++++--------- 11 files changed, 190 insertions(+), 152 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift rename Sources/CodeEditSourceEditor/{Highlighting/StyledRangeContainer => }/StyledRangeStore/StyledRangeStore+Coalesce.swift (89%) rename Sources/CodeEditSourceEditor/{Highlighting/StyledRangeContainer => }/StyledRangeStore/StyledRangeStore+FindIndex.swift (100%) rename Sources/CodeEditSourceEditor/{Highlighting/StyledRangeContainer => }/StyledRangeStore/StyledRangeStore+OffsetMetric.swift (100%) rename Sources/CodeEditSourceEditor/{Highlighting/StyledRangeContainer => }/StyledRangeStore/StyledRangeStore+StyledRun.swift (79%) rename Sources/CodeEditSourceEditor/{Highlighting/StyledRangeContainer => }/StyledRangeStore/StyledRangeStore.swift (79%) create mode 100644 Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStoreRun.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 884415bf0..4a9b0c9e4 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -276,7 +276,7 @@ extension Highlighter: StyledRangeContainerDelegate { guard let range = NSRange(location: offset, length: run.length).intersection(range) else { continue } - storage?.setAttributes(attributeProvider.attributesFor(run.capture), range: range) + storage?.setAttributes(attributeProvider.attributesFor(run.value?.capture), range: range) offset += range.length } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 6ac69bcc7..f159c25f7 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -18,7 +18,34 @@ protocol StyledRangeContainerDelegate: AnyObject { /// See ``runsIn(range:)`` for more details on how conflicting highlights are handled. @MainActor class StyledRangeContainer { - var _storage: [ProviderID: StyledRangeStore] = [:] + struct StyleElement: StyledRangeStoreElement, CustomDebugStringConvertible { + var capture: CaptureName? + var modifiers: CaptureModifierSet + + var isEmpty: Bool { + capture == nil && modifiers.isEmpty + } + + func combineLowerPriority(_ other: StyleElement?) -> StyleElement { + StyleElement( + capture: self.capture ?? other?.capture, + modifiers: modifiers.union(other?.modifiers ?? []) + ) + } + + func combineHigherPriority(_ other: StyleElement?) -> StyleElement { + StyleElement( + capture: other?.capture ?? self.capture, + modifiers: modifiers.union(other?.modifiers ?? []) + ) + } + + var debugDescription: String { + "\(capture?.stringValue ?? "(empty)"), \(modifiers)" + } + } + + var _storage: [ProviderID: StyledRangeStore] = [:] weak var delegate: StyledRangeContainerDelegate? /// Initialize the container with a list of provider identifiers. Each provider is given an id, they should be @@ -28,13 +55,13 @@ class StyledRangeContainer { /// - providers: An array of identifiers given to providers. init(documentLength: Int, providers: [ProviderID]) { for provider in providers { - _storage[provider] = StyledRangeStore(documentLength: documentLength) + _storage[provider] = StyledRangeStore(documentLength: documentLength) } } func addProvider(_ id: ProviderID, documentLength: Int) { assert(!_storage.keys.contains(id), "Provider already exists") - _storage[id] = StyledRangeStore(documentLength: documentLength) + _storage[id] = StyledRangeStore(documentLength: documentLength) } func removeProvider(_ id: ProviderID) { @@ -55,10 +82,10 @@ class StyledRangeContainer { /// /// - Parameter range: The range to query. /// - Returns: An array of continuous styled runs. - func runsIn(range: NSRange) -> [StyledRangeStoreRun] { + func runsIn(range: NSRange) -> [StyledRangeStoreRun] { // Ordered by priority, lower = higher priority. var allRuns = _storage.sorted(by: { $0.key < $1.key }).map { $0.value.runs(in: range.intRange) } - var runs: [StyledRangeStoreRun] = [] + var runs: [StyledRangeStoreRun] = [] var minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) @@ -93,8 +120,8 @@ class StyledRangeContainer { } func storageUpdated(replacedContentIn range: Range, withCount newLength: Int) { - _storage.values.forEach { - $0.storageUpdated(replacedCharactersIn: range, withCount: newLength) + for (key, value) in _storage { + _storage[key]?.storageUpdated(replacedCharactersIn: range, withCount: newLength) } } } @@ -109,11 +136,11 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { /// - rangeToHighlight: The range to apply the highlights to. func applyHighlightResult(provider: ProviderID, highlights: [HighlightRange], rangeToHighlight: NSRange) { assert(rangeToHighlight != .notFound, "NSNotFound is an invalid highlight range") - guard let storage = _storage[provider] else { + guard var storage = _storage[provider] else { assertionFailure("No storage found for the given provider: \(provider)") return } - var runs: [StyledRangeStoreRun] = [] + var runs: [StyledRangeStoreRun] = [] var lastIndex = rangeToHighlight.lowerBound for highlight in highlights { @@ -123,10 +150,9 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { continue // Skip! Overlapping } runs.append( - StyledRangeStoreRun( + StyledRangeStoreRun( length: highlight.range.length, - capture: highlight.capture, - modifiers: highlight.modifiers + value: StyleElement(capture: highlight.capture, modifiers: highlight.modifiers) ) ) lastIndex = highlight.range.max @@ -137,6 +163,7 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { } storage.set(runs: runs, for: rangeToHighlight.intRange) + _storage[provider] = storage delegate?.styleContainerDidUpdate(in: rangeToHighlight) } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift deleted file mode 100644 index 06335edba..000000000 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// StyledRangeStoreRun.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 11/4/24. -// - -/// Consumer-facing value type for the stored values in this container. -struct StyledRangeStoreRun: Equatable, Hashable { - var length: Int - var capture: CaptureName? - var modifiers: CaptureModifierSet - - static func empty(length: Int) -> Self { - StyledRangeStoreRun(length: length, capture: nil, modifiers: []) - } - - var isEmpty: Bool { - capture == nil && modifiers.isEmpty - } - - mutating package func combineLowerPriority(_ other: borrowing StyledRangeStoreRun) { - if self.capture == nil { - self.capture = other.capture - } - self.modifiers.formUnion(other.modifiers) - } - - mutating package func combineHigherPriority(_ other: borrowing StyledRangeStoreRun) { - self.capture = other.capture ?? self.capture - self.modifiers.formUnion(other.modifiers) - } - - mutating package func subtractLength(_ other: borrowing StyledRangeStoreRun) { - self.length -= other.length - } -} - -extension StyledRangeStoreRun: CustomDebugStringConvertible { - var debugDescription: String { - if isEmpty { - "\(length) (empty)" - } else { - "\(length) (\(capture.debugDescription), \(modifiers.values.debugDescription))" - } - } -} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Coalesce.swift b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+Coalesce.swift similarity index 89% rename from Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Coalesce.swift rename to Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+Coalesce.swift index 542661b5a..39f26612e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Coalesce.swift +++ b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+Coalesce.swift @@ -16,7 +16,7 @@ extension StyledRangeStore { /// rather than the queried one. /// /// - Parameter range: The range of the item to coalesce around. - func coalesceNearby(range: Range) { + mutating func coalesceNearby(range: Range) { var index = findIndex(at: range.lastIndex).index if index < _guts.endIndex && _guts.index(after: index) != _guts.endIndex { coalesceRunAfter(index: &index) @@ -30,11 +30,11 @@ extension StyledRangeStore { } /// Check if the run and the run after it are equal, and if so remove the next one and concatenate the two. - private func coalesceRunAfter(index: inout Index) { + private mutating func coalesceRunAfter(index: inout Index) { let thisRun = _guts[index] let nextRun = _guts[_guts.index(after: index)] - if thisRun.styleCompare(nextRun) { + if thisRun.compareValue(nextRun) { _guts.update(at: &index, by: { $0.length += nextRun.length }) var nextIndex = index diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+FindIndex.swift b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+FindIndex.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+FindIndex.swift rename to Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+FindIndex.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+OffsetMetric.swift b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+OffsetMetric.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+OffsetMetric.swift rename to Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+OffsetMetric.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+StyledRun.swift similarity index 79% rename from Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift rename to Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+StyledRun.swift index 3fe15a150..ea45402d9 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift +++ b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+StyledRun.swift @@ -9,18 +9,25 @@ import _RopeModule extension StyledRangeStore { struct StyledRun { var length: Int - let capture: CaptureName? - let modifiers: CaptureModifierSet + let value: Element? static func empty(length: Int) -> Self { - StyledRun(length: length, capture: nil, modifiers: []) + StyledRun(length: length, value: nil) } /// Compare two styled ranges by their stored styles. /// - Parameter other: The range to compare to. /// - Returns: The result of the comparison. - func styleCompare(_ other: Self) -> Bool { - capture == other.capture && modifiers == other.modifiers + func compareValue(_ other: Self) -> Bool { + return if let lhs = value, let rhs = other.value { + lhs == rhs + } else if let lhs = value { + lhs.isEmpty + } else if let rhs = other.value { + rhs.isEmpty + } else { + true + } } } } @@ -50,7 +57,7 @@ extension StyledRangeStore.StyledRun: RopeElement { mutating func split(at index: Self.Index) -> Self { assert(index >= 0 && index <= length) - let tail = Self(length: length - index, capture: capture, modifiers: modifiers) + let tail = Self(length: length - index, value: value) length = index return tail } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore.swift similarity index 79% rename from Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift rename to Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore.swift index 68d4056da..407f4fbf0 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore.swift @@ -12,10 +12,11 @@ import _RopeModule /// /// Internally this class uses a `Rope` from the swift-collections package, allowing for efficient updates and /// retrievals. -final class StyledRangeStore { - typealias Run = StyledRangeStoreRun - typealias Index = Rope.Index - var _guts = Rope() +struct StyledRangeStore: Sendable { + typealias Run = StyledRangeStoreRun + typealias RopeType = Rope + typealias Index = RopeType.Index + var _guts = RopeType() var length: Int { _guts.count(in: OffsetMetric()) @@ -26,7 +27,7 @@ final class StyledRangeStore { private var cache: (range: Range, runs: [Run])? init(documentLength: Int) { - self._guts = Rope([StyledRun(length: documentLength, capture: nil, modifiers: [])]) + self._guts = RopeType([StyledRun(length: documentLength, value: nil)]) } // MARK: - Core @@ -48,7 +49,7 @@ final class StyledRangeStore { while index < _guts.endIndex { let run = _guts[index] - runs.append(Run(length: run.length - (offset ?? 0), capture: run.capture, modifiers: run.modifiers)) + runs.append(Run(length: run.length - (offset ?? 0), value: run.value)) index = _guts.index(after: index) offset = nil @@ -57,22 +58,21 @@ final class StyledRangeStore { return runs } - /// Sets a capture and modifiers for a range. + /// Sets a value for a range. /// - Parameters: - /// - capture: The capture to set. - /// - modifiers: The modifiers to set. + /// - value: The value to set for the given range. /// - range: The range to write to. - func set(capture: CaptureName, modifiers: CaptureModifierSet, for range: Range) { + mutating func set(value: Element, for range: Range) { assert(range.lowerBound >= 0, "Negative lowerBound") assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") - set(runs: [Run(length: range.length, capture: capture, modifiers: modifiers)], for: range) + set(runs: [Run(length: range.length, value: value)], for: range) } /// Replaces a range in the document with an array of runs. /// - Parameters: /// - runs: The runs to insert. /// - range: The range to replace. - func set(runs: [Run], for range: Range) { + mutating func set(runs: [Run], for range: Range) { let gutsRange = 0..<_guts.count(in: OffsetMetric()) if range.clamped(to: gutsRange) != range { let upperBound = range.clamped(to: gutsRange).upperBound @@ -83,7 +83,7 @@ final class StyledRangeStore { _guts.replaceSubrange( range, in: OffsetMetric(), - with: runs.map { StyledRun(length: $0.length, capture: $0.capture, modifiers: $0.modifiers) } + with: runs.map { StyledRun(length: $0.length, value: $0.value) } ) coalesceNearby(range: range) @@ -95,7 +95,7 @@ final class StyledRangeStore { extension StyledRangeStore { /// Handles keeping the internal storage in sync with the document. - func storageUpdated(replacedCharactersIn range: Range, withCount newLength: Int) { + mutating func storageUpdated(replacedCharactersIn range: Range, withCount newLength: Int) { assert(range.lowerBound >= 0, "Negative lowerBound") assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") diff --git a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStoreRun.swift b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStoreRun.swift new file mode 100644 index 000000000..6e3b0deb9 --- /dev/null +++ b/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStoreRun.swift @@ -0,0 +1,48 @@ +// +// StyledRangeStoreRun.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 11/4/24. +// + +protocol StyledRangeStoreElement: Equatable, Hashable { + var isEmpty: Bool { get } + func combineLowerPriority(_ other: Self?) -> Self + func combineHigherPriority(_ other: Self?) -> Self +} + +/// Consumer-facing value type for the stored values in this container. +struct StyledRangeStoreRun: Equatable, Hashable { + var length: Int + var value: Element? + + static func empty(length: Int) -> Self { + StyledRangeStoreRun(length: length, value: nil) + } + + var isEmpty: Bool { + value?.isEmpty ?? true + } + + mutating func combineLowerPriority(_ other: StyledRangeStoreRun) { + value = value?.combineLowerPriority(other.value) ?? other.value + } + + mutating func combineHigherPriority(_ other: StyledRangeStoreRun) { + value = value?.combineHigherPriority(other.value) ?? other.value + } + + mutating func subtractLength(_ other: borrowing StyledRangeStoreRun) { + self.length -= other.length + } +} + +extension StyledRangeStoreRun: CustomDebugStringConvertible { + var debugDescription: String { + if let value = value as? CustomDebugStringConvertible { + "\(length) (\(value.debugDescription))" + } else { + "\(length) (empty)" + } + } +} diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift index 1ea05fc20..6506f2eb0 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import CodeEditSourceEditor final class StyledRangeContainerTests: XCTestCase { - typealias Run = StyledRangeStoreRun + typealias Run = StyledRangeStoreRun @MainActor func test_init() { @@ -27,16 +27,16 @@ final class StyledRangeContainerTests: XCTestCase { XCTAssertNotNil(store._storage[providers[0]]) XCTAssertEqual(store._storage[providers[0]]!.count, 3) - XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[0].capture) - XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[1].capture, .comment) - XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[2].capture) + XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[0].value?.capture) + XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[1].value?.capture, .comment) + XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[2].value?.capture) XCTAssertEqual( store.runsIn(range: NSRange(location: 0, length: 100)), [ - Run(length: 40, capture: nil, modifiers: []), - Run(length: 10, capture: .comment, modifiers: []), - Run(length: 50, capture: nil, modifiers: []) + Run(length: 40, value: nil), + Run(length: 10, value: .init(capture: .comment, modifiers: [])), + Run(length: 50, value: nil) ] ) } @@ -63,10 +63,10 @@ final class StyledRangeContainerTests: XCTestCase { XCTAssertEqual( store.runsIn(range: NSRange(location: 0, length: 100)), [ - Run(length: 40, capture: nil, modifiers: []), - Run(length: 5, capture: .comment, modifiers: []), - Run(length: 5, capture: .comment, modifiers: [.declaration]), - Run(length: 50, capture: nil, modifiers: []) + Run(length: 40, value: nil), + Run(length: 5, value: .init(capture: .comment, modifiers: [])), + Run(length: 5, value: .init(capture: .comment, modifiers: [.declaration])), + Run(length: 50, value: nil) ] ) } @@ -107,16 +107,16 @@ final class StyledRangeContainerTests: XCTestCase { XCTAssertEqual(runs.reduce(0, { $0 + $1.length}), 200) - XCTAssertEqual(runs[0], Run(length: 30, capture: nil, modifiers: [])) - XCTAssertEqual(runs[1], Run(length: 5, capture: .comment, modifiers: [])) - XCTAssertEqual(runs[2], Run(length: 5, capture: .comment, modifiers: [.declaration])) - XCTAssertEqual(runs[3], Run(length: 5, capture: .comment, modifiers: [.abstract, .declaration])) - XCTAssertEqual(runs[4], Run(length: 5, capture: .comment, modifiers: [])) - XCTAssertEqual(runs[5], Run(length: 30, capture: nil, modifiers: [])) - XCTAssertEqual(runs[6], Run(length: 10, capture: .string, modifiers: [])) - XCTAssertEqual(runs[7], Run(length: 10, capture: .string, modifiers: [.static])) - XCTAssertEqual(runs[8], Run(length: 5, capture: .string, modifiers: [.static, .modification])) - XCTAssertEqual(runs[9], Run(length: 5, capture: .string, modifiers: [.modification])) - XCTAssertEqual(runs[10], Run(length: 90, capture: nil, modifiers: [])) + XCTAssertEqual(runs[0], Run(length: 30, value: nil)) + XCTAssertEqual(runs[1], Run(length: 5, value: .init(capture: .comment, modifiers: []))) + XCTAssertEqual(runs[2], Run(length: 5, value: .init(capture: .comment, modifiers: [.declaration]))) + XCTAssertEqual(runs[3], Run(length: 5, value: .init(capture: .comment, modifiers: [.abstract, .declaration]))) + XCTAssertEqual(runs[4], Run(length: 5, value: .init(capture: .comment, modifiers: []))) + XCTAssertEqual(runs[5], Run(length: 30, value: nil)) + XCTAssertEqual(runs[6], Run(length: 10, value: .init(capture: .string, modifiers: []))) + XCTAssertEqual(runs[7], Run(length: 10, value: .init(capture: .string, modifiers: [.static]))) + XCTAssertEqual(runs[8], Run(length: 5, value: .init(capture: .string, modifiers: [.static, .modification]))) + XCTAssertEqual(runs[9], Run(length: 5, value: .init(capture: .string, modifiers: [.modification]))) + XCTAssertEqual(runs[10], Run(length: 90, value: nil)) } } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift index 5b43d5d2e..7a50e185d 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -7,6 +7,8 @@ extension StyledRangeStore { } final class StyledRangeStoreTests: XCTestCase { + typealias Store = StyledRangeStore + override var continueAfterFailure: Bool { get { false } set { } @@ -15,7 +17,7 @@ final class StyledRangeStoreTests: XCTestCase { func test_initWithLength() { for _ in 0..<100 { let length = Int.random(in: 0..<1000) - let store = StyledRangeStore(documentLength: length) + var store = Store(documentLength: length) XCTAssertEqual(store.length, length) } } @@ -23,26 +25,27 @@ final class StyledRangeStoreTests: XCTestCase { // MARK: - Storage func test_storageRemoveCharacters() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 10..<12, withCount: 0) XCTAssertEqual(store.length, 98, "Failed to remove correct range") XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageRemoveFromEnd() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 95..<100, withCount: 0) XCTAssertEqual(store.length, 95, "Failed to remove correct range") XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageRemoveSingleCharacterFromEnd() { - let store = StyledRangeStore(documentLength: 10) + var store = Store(documentLength: 10) store.set( // Test that we can delete a character associated with a single syntax run too runs: [ .empty(length: 8), - .init(length: 1, modifiers: [.abstract]), - .init(length: 1, modifiers: [.declaration])], + .init(length: 1, value: .init(modifiers: [.abstract])), + .init(length: 1, value: .init(modifiers: [.declaration])) + ], for: 0..<10 ) store.storageUpdated(replacedCharactersIn: 9..<10, withCount: 0) @@ -51,70 +54,70 @@ final class StyledRangeStoreTests: XCTestCase { } func test_storageRemoveFromBeginning() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<15, withCount: 0) XCTAssertEqual(store.length, 85, "Failed to remove correct range") XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageRemoveAll() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<100, withCount: 0) XCTAssertEqual(store.length, 0, "Failed to remove correct range") XCTAssertEqual(store.count, 0, "Failed to remove all runs") } func test_storageInsert() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 45..<45, withCount: 10) XCTAssertEqual(store.length, 110) XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageInsertAtEnd() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 100..<100, withCount: 10) XCTAssertEqual(store.length, 110) XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageInsertAtBeginning() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<0, withCount: 10) XCTAssertEqual(store.length, 110) XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageInsertFromEmpty() { - let store = StyledRangeStore(documentLength: 0) + var store = Store(documentLength: 0) store.storageUpdated(replacedCharactersIn: 0..<0, withCount: 10) XCTAssertEqual(store.length, 10) XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageEdit() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 45..<50, withCount: 10) XCTAssertEqual(store.length, 105) XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageEditAtEnd() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 95..<100, withCount: 10) XCTAssertEqual(store.length, 105) XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageEditAtBeginning() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<5, withCount: 10) XCTAssertEqual(store.length, 105) XCTAssertEqual(store.count, 1, "Failed to coalesce") } func test_storageEditAll() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<100, withCount: 10) XCTAssertEqual(store.length, 10) XCTAssertEqual(store.count, 1, "Failed to coalesce") @@ -123,8 +126,8 @@ final class StyledRangeStoreTests: XCTestCase { // MARK: - Styles func test_setOneRun() { - let store = StyledRangeStore(documentLength: 100) - store.set(capture: .comment, modifiers: [.static], for: 45..<50) + var store = Store(documentLength: 100) + store.set(value: .init(capture: .comment, modifiers: [.static]), for: 45..<50) XCTAssertEqual(store.length, 100) XCTAssertEqual(store.count, 3) @@ -134,18 +137,18 @@ final class StyledRangeStoreTests: XCTestCase { XCTAssertEqual(runs[1].length, 5) XCTAssertEqual(runs[2].length, 50) - XCTAssertNil(runs[0].capture) - XCTAssertEqual(runs[1].capture, .comment) - XCTAssertNil(runs[2].capture) + XCTAssertNil(runs[0].value?.capture) + XCTAssertEqual(runs[1].value?.capture, .comment) + XCTAssertNil(runs[2].value?.capture) - XCTAssertEqual(runs[0].modifiers, []) - XCTAssertEqual(runs[1].modifiers, [.static]) - XCTAssertEqual(runs[2].modifiers, []) + XCTAssertEqual(runs[0].value?.modifiers, nil) + XCTAssertEqual(runs[1].value?.modifiers, [.static]) + XCTAssertEqual(runs[2].value?.modifiers, nil) } func test_queryOverlappingRun() { - let store = StyledRangeStore(documentLength: 100) - store.set(capture: .comment, modifiers: [.static], for: 45..<50) + var store = Store(documentLength: 100) + store.set(value: .init(capture: .comment, modifiers: [.static]), for: 45..<50) XCTAssertEqual(store.length, 100) XCTAssertEqual(store.count, 3) @@ -154,21 +157,21 @@ final class StyledRangeStoreTests: XCTestCase { XCTAssertEqual(runs[0].length, 3) XCTAssertEqual(runs[1].length, 50) - XCTAssertEqual(runs[0].capture, .comment) - XCTAssertNil(runs[1].capture) + XCTAssertEqual(runs[0].value?.capture, .comment) + XCTAssertNil(runs[1].value?.capture) - XCTAssertEqual(runs[0].modifiers, [.static]) - XCTAssertEqual(runs[1].modifiers, []) + XCTAssertEqual(runs[0].value?.modifiers, [.static]) + XCTAssertEqual(runs[1].value?.modifiers, nil) } func test_setMultipleRuns() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) - store.set(capture: .comment, modifiers: [.static], for: 5..<15) - store.set(capture: .keyword, modifiers: [], for: 20..<30) - store.set(capture: .string, modifiers: [.static], for: 35..<40) - store.set(capture: .function, modifiers: [], for: 45..<50) - store.set(capture: .variable, modifiers: [], for: 60..<70) + store.set(value: .init(capture: .comment, modifiers: [.static]), for: 5..<15) + store.set(value: .init(capture: .keyword, modifiers: []), for: 20..<30) + store.set(value: .init(capture: .string, modifiers: [.static]), for: 35..<40) + store.set(value: .init(capture: .function, modifiers: []), for: 45..<50) + store.set(value: .init(capture: .variable, modifiers: []), for: 60..<70) XCTAssertEqual(store.length, 100) @@ -182,13 +185,13 @@ final class StyledRangeStoreTests: XCTestCase { runs.enumerated().forEach { XCTAssertEqual($0.element.length, lengths[$0.offset]) - XCTAssertEqual($0.element.capture, captures[$0.offset]) - XCTAssertEqual($0.element.modifiers, modifiers[$0.offset]) + XCTAssertEqual($0.element.value?.capture, captures[$0.offset]) + XCTAssertEqual($0.element.value?.modifiers ?? [], modifiers[$0.offset]) } } func test_setMultipleRunsAndStorageUpdate() { - let store = StyledRangeStore(documentLength: 100) + var store = Store(documentLength: 100) var lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] var captures: [CaptureName?] = [nil, .comment, nil, .keyword, nil, .string, nil, .function, nil, .variable, nil] @@ -196,7 +199,7 @@ final class StyledRangeStoreTests: XCTestCase { store.set( runs: zip(zip(lengths, captures), modifiers).map { - StyledRangeStore.Run(length: $0.0, capture: $0.1, modifiers: $1) + Store.Run(length: $0.0, value: .init(capture: $0.1, modifiers: $1)) }, for: 0..<100 ) @@ -214,14 +217,14 @@ final class StyledRangeStoreTests: XCTestCase { "Run \($0.offset) has incorrect length: \($0.element.length). Expected \(lengths[$0.offset])" ) XCTAssertEqual( - $0.element.capture, + $0.element.value?.capture, captures[$0.offset], // swiftlint:disable:next line_length - "Run \($0.offset) has incorrect capture: \(String(describing: $0.element.capture)). Expected \(String(describing: captures[$0.offset]))" + "Run \($0.offset) has incorrect capture: \(String(describing: $0.element.value?.capture)). Expected \(String(describing: captures[$0.offset]))" ) XCTAssertEqual( - $0.element.modifiers, - modifiers[$0.offset], - "Run \($0.offset) has incorrect modifiers: \($0.element.modifiers). Expected \(modifiers[$0.offset])" + $0.element.value?.modifiers, + modifiers[$0.offset], // swiftlint:disable:next line_length + "Run \($0.offset) has incorrect modifiers: \(String(describing: $0.element.value?.modifiers)). Expected \(modifiers[$0.offset])" ) } @@ -236,8 +239,8 @@ final class StyledRangeStoreTests: XCTestCase { runs.enumerated().forEach { XCTAssertEqual($0.element.length, lengths[$0.offset]) - XCTAssertEqual($0.element.capture, captures[$0.offset]) - XCTAssertEqual($0.element.modifiers, modifiers[$0.offset]) + XCTAssertEqual($0.element.value?.capture, captures[$0.offset]) + XCTAssertEqual($0.element.value?.modifiers ?? [], modifiers[$0.offset]) } } } From c7d78235dfc90c462ad46636f1a7d3c990182e6a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 28 May 2025 16:19:32 -0500 Subject: [PATCH 14/33] Rename to `RangeStore` --- .../Highlighting/Highlighter.swift | 4 ++-- .../StyledRangeContainer.swift | 6 +++--- .../RangeStore+Coalesce.swift} | 4 ++-- .../RangeStore+FindIndex.swift} | 4 ++-- .../RangeStore+OffsetMetric.swift} | 8 ++++---- .../RangeStore+StyledRun.swift} | 16 ++++++++-------- .../RangeStore.swift} | 8 ++++---- .../RangeStoreRun.swift} | 0 .../Highlighting/StyledRangeStoreTests.swift | 4 ++-- 9 files changed, 27 insertions(+), 27 deletions(-) rename Sources/CodeEditSourceEditor/{StyledRangeStore/StyledRangeStore+Coalesce.swift => RangeStore/RangeStore+Coalesce.swift} (95%) rename Sources/CodeEditSourceEditor/{StyledRangeStore/StyledRangeStore+FindIndex.swift => RangeStore/RangeStore+FindIndex.swift} (85%) rename Sources/CodeEditSourceEditor/{StyledRangeStore/StyledRangeStore+OffsetMetric.swift => RangeStore/RangeStore+OffsetMetric.swift} (52%) rename Sources/CodeEditSourceEditor/{StyledRangeStore/StyledRangeStore+StyledRun.swift => RangeStore/RangeStore+StyledRun.swift} (81%) rename Sources/CodeEditSourceEditor/{StyledRangeStore/StyledRangeStore.swift => RangeStore/RangeStore.swift} (94%) rename Sources/CodeEditSourceEditor/{StyledRangeStore/StyledRangeStoreRun.swift => RangeStore/RangeStoreRun.swift} (100%) diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 4a9b0c9e4..31c1a2a1d 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -17,7 +17,7 @@ import OSLog /// /// This class manages multiple objects that help perform this task: /// - ``StyledRangeContainer`` -/// - ``StyledRangeStore`` +/// - ``RangeStore`` /// - ``VisibleRangeProvider`` /// - ``HighlightProviderState`` /// @@ -35,7 +35,7 @@ import OSLog /// | Queries coalesced styles /// v /// +-------------------------------+ +-----------------------------+ -/// | StyledRangeContainer | ------> | StyledRangeStore[] | +/// | StyledRangeContainer | ------> | RangeStore[] | /// | | | | Stores styles for one provider /// | - manages combined ranges | | - stores raw ranges & | /// | - layers highlight styles | | captures | diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index f159c25f7..c971376d2 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -45,7 +45,7 @@ class StyledRangeContainer { } } - var _storage: [ProviderID: StyledRangeStore] = [:] + var _storage: [ProviderID: RangeStore] = [:] weak var delegate: StyledRangeContainerDelegate? /// Initialize the container with a list of provider identifiers. Each provider is given an id, they should be @@ -55,13 +55,13 @@ class StyledRangeContainer { /// - providers: An array of identifiers given to providers. init(documentLength: Int, providers: [ProviderID]) { for provider in providers { - _storage[provider] = StyledRangeStore(documentLength: documentLength) + _storage[provider] = RangeStore(documentLength: documentLength) } } func addProvider(_ id: ProviderID, documentLength: Int) { assert(!_storage.keys.contains(id), "Provider already exists") - _storage[id] = StyledRangeStore(documentLength: documentLength) + _storage[id] = RangeStore(documentLength: documentLength) } func removeProvider(_ id: ProviderID) { diff --git a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+Coalesce.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+Coalesce.swift similarity index 95% rename from Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+Coalesce.swift rename to Sources/CodeEditSourceEditor/RangeStore/RangeStore+Coalesce.swift index 39f26612e..d6fb93259 100644 --- a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+Coalesce.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+Coalesce.swift @@ -1,5 +1,5 @@ // -// StyledRangeStore+Internals.swift +// RangeStore+Internals.swift // CodeEditSourceEditor // // Created by Khan Winter on 10/25/24 @@ -7,7 +7,7 @@ import _RopeModule -extension StyledRangeStore { +extension RangeStore { /// Coalesce items before and after the given range. /// /// Compares the next run with the run at the given range. If they're the same, removes the next run and grows the diff --git a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+FindIndex.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift similarity index 85% rename from Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+FindIndex.swift rename to Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift index a07076b58..363768d2e 100644 --- a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+FindIndex.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift @@ -1,11 +1,11 @@ // -// StyledRangeStore+FindIndex.swift +// RangeStore+FindIndex.swift // CodeEditSourceEditor // // Created by Khan Winter on 1/6/25. // -extension StyledRangeStore { +extension RangeStore { /// Finds a Rope index, given a string offset. /// - Parameter offset: The offset to query for. /// - Returns: The index of the containing element in the rope. diff --git a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+OffsetMetric.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift similarity index 52% rename from Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+OffsetMetric.swift rename to Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift index a05b68f68..2a74bf166 100644 --- a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+OffsetMetric.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift @@ -1,5 +1,5 @@ // -// StyledRangeStore+OffsetMetric.swift +// RangeStore+OffsetMetric.swift // CodeEditSourceEditor // // Created by Khan Winter on 10/25/24 @@ -7,15 +7,15 @@ import _RopeModule -extension StyledRangeStore { +extension RangeStore { struct OffsetMetric: RopeMetric { typealias Element = StyledRun - func size(of summary: StyledRangeStore.StyledRun.Summary) -> Int { + func size(of summary: RangeStore.StyledRun.Summary) -> Int { summary.length } - func index(at offset: Int, in element: StyledRangeStore.StyledRun) -> Int { + func index(at offset: Int, in element: RangeStore.StyledRun) -> Int { return offset } } diff --git a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+StyledRun.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+StyledRun.swift similarity index 81% rename from Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+StyledRun.swift rename to Sources/CodeEditSourceEditor/RangeStore/RangeStore+StyledRun.swift index ea45402d9..823eb54d0 100644 --- a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore+StyledRun.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+StyledRun.swift @@ -1,12 +1,12 @@ // -// StyledRangeStore+StyledRun.swift +// RangeStore+StyledRun.swift // CodeEditSourceEditor // // Created by Khan Winter on 10/25/24 import _RopeModule -extension StyledRangeStore { +extension RangeStore { struct StyledRun { var length: Int let value: Element? @@ -32,7 +32,7 @@ extension StyledRangeStore { } } -extension StyledRangeStore.StyledRun: RopeElement { +extension RangeStore.StyledRun: RopeElement { typealias Index = Int var summary: Summary { Summary(length: length) } @@ -63,28 +63,28 @@ extension StyledRangeStore.StyledRun: RopeElement { } } -extension StyledRangeStore.StyledRun { +extension RangeStore.StyledRun { struct Summary { var length: Int } } -extension StyledRangeStore.StyledRun.Summary: RopeSummary { +extension RangeStore.StyledRun.Summary: RopeSummary { // FIXME: This is entirely arbitrary. Benchmark this. @inline(__always) static var maxNodeSize: Int { 10 } @inline(__always) - static var zero: StyledRangeStore.StyledRun.Summary { Self(length: 0) } + static var zero: RangeStore.StyledRun.Summary { Self(length: 0) } @inline(__always) var isZero: Bool { length == 0 } - mutating func add(_ other: StyledRangeStore.StyledRun.Summary) { + mutating func add(_ other: RangeStore.StyledRun.Summary) { length += other.length } - mutating func subtract(_ other: StyledRangeStore.StyledRun.Summary) { + mutating func subtract(_ other: RangeStore.StyledRun.Summary) { length -= other.length } } diff --git a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift similarity index 94% rename from Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore.swift rename to Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 407f4fbf0..2ebd7d488 100644 --- a/Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift @@ -1,5 +1,5 @@ // -// StyledRangeStore.swift +// RangeStore.swift // CodeEditSourceEditor // // Created by Khan Winter on 10/24/24 @@ -7,12 +7,12 @@ import _RopeModule -/// StyledRangeStore is a container type that allows for setting and querying captures and modifiers for syntax +/// RangeStore is a container type that allows for setting and querying captures and modifiers for syntax /// highlighting. The container reflects a text document in that its length needs to be kept up-to-date. /// /// Internally this class uses a `Rope` from the swift-collections package, allowing for efficient updates and /// retrievals. -struct StyledRangeStore: Sendable { +struct RangeStore: Sendable { typealias Run = StyledRangeStoreRun typealias RopeType = Rope typealias Index = RopeType.Index @@ -93,7 +93,7 @@ struct StyledRangeStore: Sendable { // MARK: - Storage Sync -extension StyledRangeStore { +extension RangeStore { /// 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/StyledRangeStore/StyledRangeStoreRun.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift similarity index 100% rename from Sources/CodeEditSourceEditor/StyledRangeStore/StyledRangeStoreRun.swift rename to Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift index 7a50e185d..502be9c31 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -1,13 +1,13 @@ import XCTest @testable import CodeEditSourceEditor -extension StyledRangeStore { +extension RangeStore { var length: Int { _guts.summary.length } var count: Int { _guts.count } } final class StyledRangeStoreTests: XCTestCase { - typealias Store = StyledRangeStore + typealias Store = RangeStore override var continueAfterFailure: Bool { get { false } From 26d2b66da0faf41f442bfde4e5d523a583d5b49e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 28 May 2025 16:25:07 -0500 Subject: [PATCH 15/33] Finish Rename --- .../StyledRangeContainer.swift | 10 +++++----- .../RangeStore/RangeStore+OffsetMetric.swift | 6 +++--- ...edRun.swift => RangeStore+StoredRun.swift} | 18 ++++++++--------- .../RangeStore/RangeStore.swift | 16 ++++++++------- .../RangeStore/RangeStoreElement.swift | 12 +++++++++++ .../RangeStore/RangeStoreRun.swift | 20 +++++++------------ .../StyledRangeContainerTests.swift | 2 +- 7 files changed, 46 insertions(+), 38 deletions(-) rename Sources/CodeEditSourceEditor/RangeStore/{RangeStore+StyledRun.swift => RangeStore+StoredRun.swift} (81%) create mode 100644 Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index c971376d2..b1971b7d0 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -18,7 +18,7 @@ protocol StyledRangeContainerDelegate: AnyObject { /// See ``runsIn(range:)`` for more details on how conflicting highlights are handled. @MainActor class StyledRangeContainer { - struct StyleElement: StyledRangeStoreElement, CustomDebugStringConvertible { + struct StyleElement: RangeStoreElement, CustomDebugStringConvertible { var capture: CaptureName? var modifiers: CaptureModifierSet @@ -82,10 +82,10 @@ class StyledRangeContainer { /// /// - Parameter range: The range to query. /// - Returns: An array of continuous styled runs. - func runsIn(range: NSRange) -> [StyledRangeStoreRun] { + func runsIn(range: NSRange) -> [RangeStoreRun] { // Ordered by priority, lower = higher priority. var allRuns = _storage.sorted(by: { $0.key < $1.key }).map { $0.value.runs(in: range.intRange) } - var runs: [StyledRangeStoreRun] = [] + var runs: [RangeStoreRun] = [] var minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) @@ -140,7 +140,7 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { assertionFailure("No storage found for the given provider: \(provider)") return } - var runs: [StyledRangeStoreRun] = [] + var runs: [RangeStoreRun] = [] var lastIndex = rangeToHighlight.lowerBound for highlight in highlights { @@ -150,7 +150,7 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { continue // Skip! Overlapping } runs.append( - StyledRangeStoreRun( + RangeStoreRun( length: highlight.range.length, value: StyleElement(capture: highlight.capture, modifiers: highlight.modifiers) ) diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift index 2a74bf166..efbfb4a12 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift @@ -9,13 +9,13 @@ import _RopeModule extension RangeStore { struct OffsetMetric: RopeMetric { - typealias Element = StyledRun + typealias Element = StoredRun - func size(of summary: RangeStore.StyledRun.Summary) -> Int { + func size(of summary: RangeStore.StoredRun.Summary) -> Int { summary.length } - func index(at offset: Int, in element: RangeStore.StyledRun) -> Int { + func index(at offset: Int, in element: RangeStore.StoredRun) -> Int { return offset } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+StyledRun.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+StoredRun.swift similarity index 81% rename from Sources/CodeEditSourceEditor/RangeStore/RangeStore+StyledRun.swift rename to Sources/CodeEditSourceEditor/RangeStore/RangeStore+StoredRun.swift index 823eb54d0..b7a7afdf1 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+StyledRun.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+StoredRun.swift @@ -1,5 +1,5 @@ // -// RangeStore+StyledRun.swift +// RangeStore+StoredRun.swift // CodeEditSourceEditor // // Created by Khan Winter on 10/25/24 @@ -7,12 +7,12 @@ import _RopeModule extension RangeStore { - struct StyledRun { + struct StoredRun { var length: Int let value: Element? static func empty(length: Int) -> Self { - StyledRun(length: length, value: nil) + StoredRun(length: length, value: nil) } /// Compare two styled ranges by their stored styles. @@ -32,7 +32,7 @@ extension RangeStore { } } -extension RangeStore.StyledRun: RopeElement { +extension RangeStore.StoredRun: RopeElement { typealias Index = Int var summary: Summary { Summary(length: length) } @@ -63,28 +63,28 @@ extension RangeStore.StyledRun: RopeElement { } } -extension RangeStore.StyledRun { +extension RangeStore.StoredRun { struct Summary { var length: Int } } -extension RangeStore.StyledRun.Summary: RopeSummary { +extension RangeStore.StoredRun.Summary: RopeSummary { // FIXME: This is entirely arbitrary. Benchmark this. @inline(__always) static var maxNodeSize: Int { 10 } @inline(__always) - static var zero: RangeStore.StyledRun.Summary { Self(length: 0) } + static var zero: RangeStore.StoredRun.Summary { Self(length: 0) } @inline(__always) var isZero: Bool { length == 0 } - mutating func add(_ other: RangeStore.StyledRun.Summary) { + mutating func add(_ other: RangeStore.StoredRun.Summary) { length += other.length } - mutating func subtract(_ other: RangeStore.StyledRun.Summary) { + mutating func subtract(_ other: RangeStore.StoredRun.Summary) { length -= other.length } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 2ebd7d488..5e22fc5f8 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift @@ -7,14 +7,16 @@ import _RopeModule -/// RangeStore is a container type that allows for setting and querying captures and modifiers for syntax -/// highlighting. The container reflects a text document in that its length needs to be kept up-to-date. +/// 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 +/// replace subranges even for large documents. Provides helper methods for keeping some state in-sync with a text +/// document's content. /// /// Internally this class uses a `Rope` from the swift-collections package, allowing for efficient updates and /// retrievals. -struct RangeStore: Sendable { - typealias Run = StyledRangeStoreRun - typealias RopeType = Rope +struct RangeStore: Sendable { + typealias Run = RangeStoreRun + typealias RopeType = Rope typealias Index = RopeType.Index var _guts = RopeType() @@ -27,7 +29,7 @@ struct RangeStore: Sendable { private var cache: (range: Range, runs: [Run])? init(documentLength: Int) { - self._guts = RopeType([StyledRun(length: documentLength, value: nil)]) + self._guts = RopeType([StoredRun(length: documentLength, value: nil)]) } // MARK: - Core @@ -83,7 +85,7 @@ struct RangeStore: Sendable { _guts.replaceSubrange( range, in: OffsetMetric(), - with: runs.map { StyledRun(length: $0.length, value: $0.value) } + with: runs.map { StoredRun(length: $0.length, value: $0.value) } ) coalesceNearby(range: range) diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift new file mode 100644 index 000000000..6b0a23b95 --- /dev/null +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift @@ -0,0 +1,12 @@ +// +// RangeStoreElement.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/28/25. +// + +protocol RangeStoreElement: Equatable, Hashable { + var isEmpty: Bool { get } + func combineLowerPriority(_ other: Self?) -> Self + func combineHigherPriority(_ other: Self?) -> Self +} diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift index 6e3b0deb9..48c68cc2c 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift @@ -1,43 +1,37 @@ // -// StyledRangeStoreRun.swift +// RangeStoreRun.swift // CodeEditSourceEditor // // Created by Khan Winter on 11/4/24. // -protocol StyledRangeStoreElement: Equatable, Hashable { - var isEmpty: Bool { get } - func combineLowerPriority(_ other: Self?) -> Self - func combineHigherPriority(_ other: Self?) -> Self -} - /// Consumer-facing value type for the stored values in this container. -struct StyledRangeStoreRun: Equatable, Hashable { +struct RangeStoreRun: Equatable, Hashable { var length: Int var value: Element? static func empty(length: Int) -> Self { - StyledRangeStoreRun(length: length, value: nil) + RangeStoreRun(length: length, value: nil) } var isEmpty: Bool { value?.isEmpty ?? true } - mutating func combineLowerPriority(_ other: StyledRangeStoreRun) { + mutating func combineLowerPriority(_ other: RangeStoreRun) { value = value?.combineLowerPriority(other.value) ?? other.value } - mutating func combineHigherPriority(_ other: StyledRangeStoreRun) { + mutating func combineHigherPriority(_ other: RangeStoreRun) { value = value?.combineHigherPriority(other.value) ?? other.value } - mutating func subtractLength(_ other: borrowing StyledRangeStoreRun) { + mutating func subtractLength(_ other: borrowing RangeStoreRun) { self.length -= other.length } } -extension StyledRangeStoreRun: CustomDebugStringConvertible { +extension RangeStoreRun: CustomDebugStringConvertible { var debugDescription: String { if let value = value as? CustomDebugStringConvertible { "\(length) (\(value.debugDescription))" diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift index 6506f2eb0..c366ad46c 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import CodeEditSourceEditor final class StyledRangeContainerTests: XCTestCase { - typealias Run = StyledRangeStoreRun + typealias Run = RangeStoreRun @MainActor func test_init() { From 7e7172cb80520d8e6d33d6f4d543cea80acfb2ff Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 28 May 2025 16:30:31 -0500 Subject: [PATCH 16/33] Fix Doc Comment Drawing --- .../Highlighting/Highlighter.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 31c1a2a1d..678e2761f 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -34,12 +34,12 @@ import OSLog /// | /// | Queries coalesced styles /// v -/// +-------------------------------+ +-----------------------------+ -/// | StyledRangeContainer | ------> | RangeStore[] | -/// | | | | Stores styles for one provider -/// | - manages combined ranges | | - stores raw ranges & | -/// | - layers highlight styles | | captures | -/// | + getAttributesForRange() | +-----------------------------+ +/// +-------------------------------+ +-------------------------+ +/// | StyledRangeContainer | ------> | RangeStore[] | +/// | | | | Stores styles for one provider +/// | - manages combined ranges | | - stores raw ranges & | +/// | - layers highlight styles | | captures | +/// | + getAttributesForRange() | +-------------------------+ /// +-------------------------------+ /// ^ /// | Sends highlighted runs From 168725835499e6bd6aceda339721452ef2927991 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 28 May 2025 16:31:19 -0500 Subject: [PATCH 17/33] Update Test Names --- .../StyledRangeStoreTests.swift => RangeStoreTests.swift} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename Tests/CodeEditSourceEditorTests/{Highlighting/StyledRangeStoreTests.swift => RangeStoreTests.swift} (99%) diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/RangeStoreTests.swift similarity index 99% rename from Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift rename to Tests/CodeEditSourceEditorTests/RangeStoreTests.swift index 502be9c31..6fa78447e 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/RangeStoreTests.swift @@ -6,7 +6,7 @@ extension RangeStore { var count: Int { _guts.count } } -final class StyledRangeStoreTests: XCTestCase { +final class RangeStoreTests: XCTestCase { typealias Store = RangeStore override var continueAfterFailure: Bool { From 0acd4585cecce74782bfa4d1d75cd59dacb4e63d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 28 May 2025 16:35:30 -0500 Subject: [PATCH 18/33] Loosen `RangeStoreElement` Requirements --- .../StyledRangeContainer.swift | 14 +++++++++++--- .../RangeStore/RangeStoreElement.swift | 2 -- .../RangeStore/RangeStoreRun.swift | 8 -------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index b1971b7d0..d61bfb009 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -83,6 +83,14 @@ class StyledRangeContainer { /// - Parameter range: The range to query. /// - Returns: An array of continuous styled runs. func runsIn(range: NSRange) -> [RangeStoreRun] { + func combineLowerPriority(_ lhs: inout RangeStoreRun, _ rhs: RangeStoreRun) { + lhs.value = lhs.value?.combineLowerPriority(rhs.value) ?? rhs.value + } + + func combineHigherPriority(_ lhs: inout RangeStoreRun, _ rhs: RangeStoreRun) { + lhs.value = lhs.value?.combineHigherPriority(rhs.value) ?? rhs.value + } + // Ordered by priority, lower = higher priority. var allRuns = _storage.sorted(by: { $0.key < $1.key }).map { $0.value.runs(in: range.intRange) } var runs: [RangeStoreRun] = [] @@ -97,9 +105,9 @@ class StyledRangeContainer { for idx in (0.., withCount newLength: Int) { - for (key, value) in _storage { + for key in _storage.keys { _storage[key]?.storageUpdated(replacedCharactersIn: range, withCount: newLength) } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift index 6b0a23b95..9db2d325e 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift @@ -7,6 +7,4 @@ protocol RangeStoreElement: Equatable, Hashable { var isEmpty: Bool { get } - func combineLowerPriority(_ other: Self?) -> Self - func combineHigherPriority(_ other: Self?) -> Self } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift index 48c68cc2c..116764eec 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift @@ -18,14 +18,6 @@ struct RangeStoreRun: Equatable, Hashable { value?.isEmpty ?? true } - mutating func combineLowerPriority(_ other: RangeStoreRun) { - value = value?.combineLowerPriority(other.value) ?? other.value - } - - mutating func combineHigherPriority(_ other: RangeStoreRun) { - value = value?.combineHigherPriority(other.value) ?? other.value - } - mutating func subtractLength(_ other: borrowing RangeStoreRun) { self.length -= other.length } From 80f534e5e31d9093bda5323a2a4f552dd5edf0f7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 29 May 2025 11:47:51 -0500 Subject: [PATCH 19/33] Update Package.resolved --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..a7030c2eb 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" } }, { From cd5b7c8a8118d6969b1cf82b4baa6008097a7665 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 29 May 2025 11:48:52 -0500 Subject: [PATCH 20/33] Remove Moved Files --- .../IndentationLineFoldProvider.swift | 34 --- .../Gutter/LineFolding/FoldRange.swift | 25 --- .../LineFolding/FoldingRibbonView.swift | 210 ------------------ .../Gutter/LineFolding/LineFoldProvider.swift | 13 -- .../Gutter/LineFolding/LineFoldingModel.swift | 155 ------------- 5 files changed, 437 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift 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.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift deleted file mode 100644 index d7d8543bf..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// FoldingRibbonView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/6/25. -// - -import Foundation -import AppKit -import CodeEditTextView - -#warning("Replace before release") -fileprivate let demoFoldProvider = IndentationLineFoldProvider() - -/// Displays the code folding ribbon in the ``GutterView``. -/// -/// This view draws its contents -class FoldingRibbonView: NSView { - static let width: CGFloat = 7.0 - - private var model: LineFoldingModel - private var hoveringLine: Int? - - @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.1) - default: - NSColor() - } - }.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 - - override public var isFlipped: Bool { - true - } - - init(textView: TextView, foldProvider: LineFoldProvider?) { - #warning("Replace before release") - self.model = LineFoldingModel( - textView: textView, - foldProvider: foldProvider ?? demoFoldProvider - ) - super.init(frame: .zero) - layerContentsRedrawPolicy = .onSetNeedsDisplay - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func updateTrackingAreas() { - trackingAreas.forEach(removeTrackingArea) - let area = NSTrackingArea( - rect: bounds, - options: [.mouseMoved, .activeInKeyWindow], - owner: self, - userInfo: nil - ) - addTrackingArea(area) - } - - 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 { - 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 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 - ) - } - } - - // Draw subfolds - for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { - drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) - } - } - - /// 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 b2e4dfbcf..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// LineFoldingModel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import AppKit -import CodeEditTextView - -/// # 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? - - 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 - } - print(foldDepth, linePosition.index) - // Start a new fold - if foldDepth > currentDepth { - let newFold = FoldRange( - lineRange: (linePosition.index - 1)...(linePosition.index - 1), - range: .zero, - parent: currentFold, - subFolds: [] - ) - if currentDepth == 0 { - foldCache.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 - } - } - - 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 findCachedFoldAt(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)? { - binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0) - } - - /// 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 - } -} From 24d9b7af8bf7c41c3e93f784117b419457daeb7b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 30 May 2025 10:55:45 -0500 Subject: [PATCH 21/33] Begin Transition To `RangeStore` Model --- .../LineFolding/Model/FoldRange.swift | 103 ++++++++++++-- .../Model/LineFoldCalculator.swift | 130 +++++++++--------- .../LineFolding/Model/LineFoldingModel.swift | 50 +------ .../View/FoldingRibbonView+Draw.swift | 62 ++++----- .../LineFolding/View/FoldingRibbonView.swift | 92 ++++++------- .../RangeStore/RangeStore+FindIndex.swift | 7 + .../LineFoldStorageTests.swift | 59 ++++++++ .../LineFoldingModelTests.swift | 84 +++++------ 8 files changed, 346 insertions(+), 241 deletions(-) create mode 100644 Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift index f775ae5b3..8a84cc02e 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift @@ -7,30 +7,107 @@ import Foundation -/// Represents a recursive folded range +/// Represents a folded range class FoldRange { - var lineRange: ClosedRange var range: NSRange var depth: Int var collapsed: Bool - /// Ordered array of ranges that are nested in this fold. - var subFolds: [FoldRange] - - weak var parent: FoldRange? init( - lineRange: ClosedRange, range: NSRange, depth: Int, - collapsed: Bool, - parent: FoldRange?, - subFolds: [FoldRange] + collapsed: Bool ) { - self.lineRange = lineRange self.range = range self.depth = depth self.collapsed = collapsed - self.subFolds = subFolds - self.parent = parent + } +} + +struct LineFoldStorage: Sendable { + struct Fold: RangeStoreElement, Sendable { + var isEmpty: Bool { depth == nil } + + var depth: Int? + var collapsed: Bool + } + + struct FoldRunInfo: Equatable, Sendable { + let depth: Int + let collapsed: Bool + let runs: [Range] + } + + var storage: RangeStore + + init(foldDepths: [(range: Range, depth: Int)], documentLength: Int) { + storage = RangeStore(documentLength: documentLength) + + for foldDepth in foldDepths { + storage.set( + value: Fold(depth: foldDepth.depth, collapsed: false), + for: foldDepth.range + ) + } + } + + func depth(at offset: Int) -> Int? { + storage.findValue(at: offset)?.depth + } + + func foldMarkers(for range: ClosedRange) -> [FoldRange] { + [] + } + + func collectRuns(forDeepestFoldAt offset: Int) -> FoldRunInfo? { + let initialIndex = storage.findIndex(at: offset).index + guard let foldRange = storage.findValue(at: offset), + let foldDepth = foldRange.depth else { + return nil + } + + var runs: [Range] = [] + + func appendRun(_ index: RangeStore.Index) { + let location = storage._guts.offset(of: index, in: RangeStore.OffsetMetric()) + let length = storage._guts[initialIndex].length + runs.append(location..<(location + length)) + } + appendRun(initialIndex) + + // Collect up + if initialIndex != storage._guts.startIndex { + var index = storage._guts.index(before: initialIndex) + while index != storage._guts.startIndex, + let nextDepth = storage._guts[index].value?.depth, + nextDepth >= foldDepth { + if nextDepth == foldDepth { + appendRun(index) + } + index = storage._guts.index(before: index) + } + } + + // Collect down + if initialIndex != storage._guts.endIndex { + var index = storage._guts.index(after: initialIndex) + while index != storage._guts.endIndex, + let nextDepth = storage._guts[index].value?.depth, + nextDepth >= foldDepth { + if nextDepth == foldDepth { + appendRun(index) + } + index = storage._guts.index(after: index) + } + } + + return FoldRunInfo(depth: foldDepth, collapsed: foldRange.collapsed, runs: runs) + } + + mutating func toggleCollapse(at offset: Int) { + guard let foldInfo = collectRuns(forDeepestFoldAt: offset) else { return } + for run in foldInfo.runs { + storage.set(value: Fold(depth: foldInfo.depth, collapsed: !foldInfo.collapsed), for: run) + } } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 0ed289377..9e6a7678e 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -47,68 +47,68 @@ class LineFoldCalculator { /// 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) { - workQueue.async { - guard let textView = self.textView, let foldProvider = self.foldProvider else { return } - var foldCache: [FoldRange] = [] - var currentFold: FoldRange? - var currentDepth: Int = 0 - var iterator = textView.layoutManager.linesInRange(textView.documentRange) - - var lines = self.getMoreLines( - textView: textView, - iterator: &iterator, - lastDepth: currentDepth, - foldProvider: foldProvider - ) - while let lineChunk = lines { - for lineInfo in lineChunk { - // Start a new fold, going deeper to a new depth. - if lineInfo.providerInfo.depth > currentDepth { - let newFold = FoldRange( - lineRange: lineInfo.lineNumber...lineInfo.lineNumber, - range: NSRange(location: lineInfo.providerInfo.rangeIndice, length: 0), - depth: lineInfo.providerInfo.depth, - collapsed: lineInfo.collapsed, - parent: currentFold, - subFolds: [] - ) - - if currentFold == nil { - foldCache.append(newFold) - } else { - currentFold?.subFolds.append(newFold) - } - currentFold = newFold - } else if lineInfo.providerInfo.depth < currentDepth { - // End this fold, go shallower "popping" folds deeper than the new depth - while let fold = currentFold, fold.depth > lineInfo.providerInfo.depth { - // close this fold at the current line - fold.lineRange = fold.lineRange.lowerBound...lineInfo.lineNumber - fold.range = NSRange(start: fold.range.location, end: lineInfo.providerInfo.rangeIndice) - // move up - currentFold = fold.parent - } - } - - currentDepth = lineInfo.providerInfo.depth - } - lines = self.getMoreLines( - textView: textView, - iterator: &iterator, - lastDepth: currentDepth, - foldProvider: foldProvider - ) - } - - // Clean up any hanging folds. - while let fold = currentFold { - fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount - 1 - fold.range = NSRange(start: fold.range.location, end: textView.documentRange.length) - currentFold = fold.parent - } - - self.rangesPublisher.send(foldCache) - } +// workQueue.async { +// guard let textView = self.textView, let foldProvider = self.foldProvider else { return } +// var foldCache: [FoldRange] = [] +// var currentFold: FoldRange? +// var currentDepth: Int = 0 +// var iterator = textView.layoutManager.linesInRange(textView.documentRange) +// +// var lines = self.getMoreLines( +// textView: textView, +// iterator: &iterator, +// lastDepth: currentDepth, +// foldProvider: foldProvider +// ) +// while let lineChunk = lines { +// for lineInfo in lineChunk { +// // Start a new fold, going deeper to a new depth. +// if lineInfo.providerInfo.depth > currentDepth { +// let newFold = FoldRange( +// lineRange: lineInfo.lineNumber...lineInfo.lineNumber, +// range: NSRange(location: lineInfo.providerInfo.rangeIndice, length: 0), +// depth: lineInfo.providerInfo.depth, +// collapsed: lineInfo.collapsed, +// parent: currentFold, +// subFolds: [] +// ) +// +// if currentFold == nil { +// foldCache.append(newFold) +// } else { +// currentFold?.subFolds.append(newFold) +// } +// currentFold = newFold +// } else if lineInfo.providerInfo.depth < currentDepth { +// // End this fold, go shallower "popping" folds deeper than the new depth +// while let fold = currentFold, fold.depth > lineInfo.providerInfo.depth { +// // close this fold at the current line +// fold.lineRange = fold.lineRange.lowerBound...lineInfo.lineNumber +// fold.range = NSRange(start: fold.range.location, end: lineInfo.providerInfo.rangeIndice) +// // move up +// currentFold = fold.parent +// } +// } +// +// currentDepth = lineInfo.providerInfo.depth +// } +// lines = self.getMoreLines( +// textView: textView, +// iterator: &iterator, +// lastDepth: currentDepth, +// foldProvider: foldProvider +// ) +// } +// +// // Clean up any hanging folds. +// while let fold = currentFold { +// fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount - 1 +// fold.range = NSRange(start: fold.range.location, end: textView.documentRange.length) +// currentFold = fold.parent +// } +// +// self.rangesPublisher.send(foldCache) +// } } private func getMoreLines( @@ -131,15 +131,11 @@ class LineFoldCalculator { count += 1 continue } - let attachments = textView.layoutManager.attachments - .getAttachmentsOverlapping(linePosition.range) - .compactMap({ $0.attachment as? LineFoldPlaceholder }) - results.append( LineInfo( lineNumber: linePosition.index, providerInfo: foldInfo, - collapsed: !attachments.isEmpty + collapsed: false ) ) count += 1 diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index fc6fd7377..3b409c2fd 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -40,7 +40,8 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { } func getFolds(in lineRange: ClosedRange) -> [FoldRange] { - foldCache.withValue { $0.filter({ $0.lineRange.overlaps(lineRange) }) } +// foldCache.withValue { $0.filter({ $0.lineRange.overlaps(lineRange) }) } + [] } func textStorage( @@ -66,48 +67,9 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { /// - 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)? { - foldCache.withValue { foldCache in - binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0, findDeepest: true) - } - } -} - -// 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, - findDeepest: Bool - ) -> (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 - if findDeepest { - return binarySearchFoldsArray( - lineNumber: lineNumber, - folds: fold.subFolds, - currentDepth: currentDepth + 1, - findDeepest: findDeepest - ) ?? (fold, currentDepth) - } else { - return (fold, currentDepth) - } - } else if lineNumber < fold.lineRange.lowerBound { - high = mid - 1 - } else { - low = mid + 1 - } - } - return nil +// foldCache.withValue { foldCache in +// binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0, findDeepest: true) +// } + nil } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index c81ab8f74..8253f6ce3 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -69,37 +69,37 @@ extension FoldingRibbonView { 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 fold.collapsed { - drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context) - } else 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) - } +// 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 fold.collapsed { +// drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context) +// } else 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 drawCollapsedFold( diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index 1a3505d79..da7050ec6 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -141,57 +141,57 @@ class FoldingRibbonView: NSView { super.mouseDown(with: event) return } - if let attachment = model.textView?.layoutManager.attachments.getAttachmentsStartingIn(fold.range.range).first { - model.textView?.layoutManager.attachments.remove(atOffset: attachment.range.location) - fold.range.collapsed = false - attachments.removeAll(where: { $0 === attachment.attachment }) - } else { - let placeholder = LineFoldPlaceholder() - model.textView?.layoutManager.attachments.add(placeholder, for: fold.range.range) - attachments.append(placeholder) - fold.range.collapsed = true - } +// if let attachment = model.textView?.layoutManager.attachments.getAttachmentsStartingIn(fold.range.range).first { +// model.textView?.layoutManager.attachments.remove(atOffset: attachment.range.location) +// fold.range.collapsed = false +// attachments.removeAll(where: { $0 === attachment.attachment }) +// } else { +// let placeholder = LineFoldPlaceholder() +// model.textView?.layoutManager.attachments.add(placeholder, for: fold.range.range) +// attachments.append(placeholder) +// fold.range.collapsed = true +// } model.textView?.needsLayout = true } override func mouseMoved(with event: NSEvent) { - let pointInView = convert(event.locationInWindow, from: nil) - guard let lineNumber = model.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 { - return - } - 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 - } - - // Don't animate these - hoverAnimationProgress = 1.0 - hoveringFold = newHoverRange +// let pointInView = convert(event.locationInWindow, from: nil) +// guard let lineNumber = model.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 { +// return +// } +// 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 +// } +// +// // Don't animate these +// hoverAnimationProgress = 1.0 +// hoveringFold = newHoverRange } override func mouseExited(with event: NSEvent) { diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift index 363768d2e..51092f457 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/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift new file mode 100644 index 000000000..fce1d62a8 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift @@ -0,0 +1,59 @@ +// +// LineFoldStorageTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/29/25. +// + +import Testing +@testable import CodeEditSourceEditor + +struct LineFoldStorageTests { + var storage = LineFoldStorage( + foldDepths: [ + (1..<9, 1), + (2..<8, 2), + (5..<6, 3) + ], + documentLength: 10 + ) + + @Test + func findDepthAtIndexes() { + #expect(storage.depth(at: 0) == nil) + #expect(storage.depth(at: 1) == 1) + #expect(storage.depth(at: 2) == 2) + #expect(storage.depth(at: 5) == 3) + #expect(storage.depth(at: 6) == 2) + #expect(storage.depth(at: 8) == 1) + #expect(storage.depth(at: 9) == nil) + } + + @Test + func getDijointRunsForDepth() { + #expect( + storage.collectRuns(forDeepestFoldAt: 5) + == LineFoldStorage.FoldRunInfo(depth: 3, collapsed: false, runs: [5..<6]) + ) + + #expect( + storage.collectRuns(forDeepestFoldAt: 2) + == LineFoldStorage.FoldRunInfo(depth: 2, collapsed: false, runs: [2..<5, 6..<9]) + ) + + #expect( + storage.collectRuns(forDeepestFoldAt: 1) + == LineFoldStorage.FoldRunInfo(depth: 1, collapsed: false, runs: [1..<2, 8..<9]) + ) + } + + @Test + mutating func toggleCollapse() { + storage.toggleCollapse(at: 1) + + #expect( + storage.collectRuns(forDeepestFoldAt: 1) + == LineFoldStorage.FoldRunInfo(depth: 1, collapsed: true, runs: [1..<2, 8..<9]) + ) + } +} diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift index 4ba76f767..e5210bb97 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -10,46 +10,50 @@ 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. - class HillPatternFoldProvider: LineFoldProvider { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { - let halfLineCount = (layoutManager.lineCount / 2) - 1 - - return if lineNumber > halfLineCount { - layoutManager.lineCount - 2 - lineNumber - } else { - lineNumber - } - } - } - - let textView: TextView - let model: LineFoldingModel - - init() { - 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 { - let provider = HillPatternFoldProvider() - model.foldProvider = provider - - model.buildFoldsForDocument() - - 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) - } +// /// Makes a fold pattern that increases until halfway through the document then goes back to zero. +// class HillPatternFoldProvider: LineFoldProvider { +// func foldLevelAtLine( +// lineNumber: Int, +// lineRange: NSRange, +// currentDepth: Int, +// text: NSTextStorage +// ) -> CodeEditSourceEditor.LineFoldProviderLineInfo? { +// let halfLineCount = (layoutManager.lineCount / 2) - 1 +// +// return if lineNumber > halfLineCount { +// layoutManager.lineCount - 2 - lineNumber +// } else { +// lineNumber +// } +// } +// } +// +// let textView: TextView +// let model: LineFoldingModel +// +// init() { +// 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 { +// let provider = HillPatternFoldProvider() +// model.foldProvider = provider +// +// model.buildFoldsForDocument() +// +// 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) +// } } From 45ccd759e13d8738b291877952e6d00243501a35 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 30 May 2025 16:52:59 -0500 Subject: [PATCH 22/33] Back to where we started! (working) --- .../xcshareddata/swiftpm/Package.resolved | 9 - Package.swift | 5 +- .../Highlighting/Highlighter.swift | 18 +- .../StyledRangeContainer.swift | 4 +- .../IndentationLineFoldProvider.swift | 22 +-- .../FoldProviders/LineFoldProvider.swift | 4 +- .../LineFolding/Model/FoldRange.swift | 113 ------------ .../Model/LineFoldCalculator.swift | 167 +++++++++--------- .../LineFolding/Model/LineFoldStorage.swift | 124 +++++++++++++ .../LineFolding/Model/LineFoldingModel.swift | 19 +- .../Placeholder/LineFoldPlaceholder.swift | 6 + .../View/FoldingRibbonView+Draw.swift | 65 +++---- .../LineFolding/View/FoldingRibbonView.swift | 99 +++++------ .../RangeStore/RangeStore.swift | 19 +- .../LineFoldStorageTests.swift | 93 +++++----- 15 files changed, 388 insertions(+), 379 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift 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/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/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 index 9cd02bc5b..af1463d1e 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift @@ -22,30 +22,32 @@ final class IndentationLineFoldProvider: LineFoldProvider { func foldLevelAtLine( lineNumber: Int, lineRange: NSRange, - currentDepth: Int, + previousDepth: Int, text: NSTextStorage - ) -> LineFoldProviderLineInfo? { + ) -> [LineFoldProviderLineInfo] { guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesWithoutNewlines)?.length, - leadingIndent > 0 else { - return nil + leadingIndent != lineRange.length else { + return [] } - if leadingIndent < currentDepth { + var foldIndicators: [LineFoldProviderLineInfo] = [] + + if leadingIndent < previousDepth { // End the fold at the start of whitespace - return .endFold(rangeEnd: lineRange.location + leadingIndent, newDepth: leadingIndent) + foldIndicators.append(.endFold(rangeEnd: lineRange.location + leadingIndent, newDepth: leadingIndent)) } // 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 nil + return foldIndicators } - if nextIndent > currentDepth, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) { - return .startFold(rangeStart: trailingWhitespace.location, newDepth: nextIndent) + if nextIndent > leadingIndent, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) { + foldIndicators.append(.startFold(rangeStart: trailingWhitespace.location, newDepth: nextIndent)) } - return nil + return foldIndicators } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift index c155d4de0..077b71a3a 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift @@ -35,7 +35,7 @@ protocol LineFoldProvider: AnyObject { func foldLevelAtLine( lineNumber: Int, lineRange: NSRange, - currentDepth: Int, + previousDepth: Int, text: NSTextStorage - ) -> LineFoldProviderLineInfo? + ) -> [LineFoldProviderLineInfo] } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift deleted file mode 100644 index 8a84cc02e..000000000 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// FoldRange.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import Foundation - -/// Represents a folded range -class FoldRange { - var range: NSRange - var depth: Int - var collapsed: Bool - - init( - range: NSRange, - depth: Int, - collapsed: Bool - ) { - self.range = range - self.depth = depth - self.collapsed = collapsed - } -} - -struct LineFoldStorage: Sendable { - struct Fold: RangeStoreElement, Sendable { - var isEmpty: Bool { depth == nil } - - var depth: Int? - var collapsed: Bool - } - - struct FoldRunInfo: Equatable, Sendable { - let depth: Int - let collapsed: Bool - let runs: [Range] - } - - var storage: RangeStore - - init(foldDepths: [(range: Range, depth: Int)], documentLength: Int) { - storage = RangeStore(documentLength: documentLength) - - for foldDepth in foldDepths { - storage.set( - value: Fold(depth: foldDepth.depth, collapsed: false), - for: foldDepth.range - ) - } - } - - func depth(at offset: Int) -> Int? { - storage.findValue(at: offset)?.depth - } - - func foldMarkers(for range: ClosedRange) -> [FoldRange] { - [] - } - - func collectRuns(forDeepestFoldAt offset: Int) -> FoldRunInfo? { - let initialIndex = storage.findIndex(at: offset).index - guard let foldRange = storage.findValue(at: offset), - let foldDepth = foldRange.depth else { - return nil - } - - var runs: [Range] = [] - - func appendRun(_ index: RangeStore.Index) { - let location = storage._guts.offset(of: index, in: RangeStore.OffsetMetric()) - let length = storage._guts[initialIndex].length - runs.append(location..<(location + length)) - } - appendRun(initialIndex) - - // Collect up - if initialIndex != storage._guts.startIndex { - var index = storage._guts.index(before: initialIndex) - while index != storage._guts.startIndex, - let nextDepth = storage._guts[index].value?.depth, - nextDepth >= foldDepth { - if nextDepth == foldDepth { - appendRun(index) - } - index = storage._guts.index(before: index) - } - } - - // Collect down - if initialIndex != storage._guts.endIndex { - var index = storage._guts.index(after: initialIndex) - while index != storage._guts.endIndex, - let nextDepth = storage._guts[index].value?.depth, - nextDepth >= foldDepth { - if nextDepth == foldDepth { - appendRun(index) - } - index = storage._guts.index(after: index) - } - } - - return FoldRunInfo(depth: foldDepth, collapsed: foldRange.collapsed, runs: runs) - } - - mutating func toggleCollapse(at offset: Int) { - guard let foldInfo = collectRuns(forDeepestFoldAt: offset) else { return } - for run in foldInfo.runs { - storage.set(value: Fold(depth: foldInfo.depth, collapsed: !foldInfo.collapsed), for: run) - } - } -} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 9e6a7678e..b42ae1d53 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -15,16 +15,10 @@ import Combine /// Fold information is emitted via `rangesPublisher`. /// Notify the calculator it should re-calculate class LineFoldCalculator { - private struct LineInfo { - let lineNumber: Int - let providerInfo: LineFoldProviderLineInfo - let collapsed: Bool - } - weak var foldProvider: LineFoldProvider? weak var textView: TextView? - var rangesPublisher = CurrentValueSubject<[FoldRange], Never>([]) + var rangesPublisher = CurrentValueSubject(.init(documentLength: 0)) private let workQueue = DispatchQueue.global(qos: .default) @@ -47,99 +41,100 @@ class LineFoldCalculator { /// 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) { -// workQueue.async { -// guard let textView = self.textView, let foldProvider = self.foldProvider else { return } -// var foldCache: [FoldRange] = [] -// var currentFold: FoldRange? -// var currentDepth: Int = 0 -// var iterator = textView.layoutManager.linesInRange(textView.documentRange) -// -// var lines = self.getMoreLines( -// textView: textView, -// iterator: &iterator, -// lastDepth: currentDepth, -// foldProvider: foldProvider -// ) -// while let lineChunk = lines { -// for lineInfo in lineChunk { -// // Start a new fold, going deeper to a new depth. -// if lineInfo.providerInfo.depth > currentDepth { -// let newFold = FoldRange( -// lineRange: lineInfo.lineNumber...lineInfo.lineNumber, -// range: NSRange(location: lineInfo.providerInfo.rangeIndice, length: 0), -// depth: lineInfo.providerInfo.depth, -// collapsed: lineInfo.collapsed, -// parent: currentFold, -// subFolds: [] -// ) -// -// if currentFold == nil { -// foldCache.append(newFold) -// } else { -// currentFold?.subFolds.append(newFold) -// } -// currentFold = newFold -// } else if lineInfo.providerInfo.depth < currentDepth { -// // End this fold, go shallower "popping" folds deeper than the new depth -// while let fold = currentFold, fold.depth > lineInfo.providerInfo.depth { -// // close this fold at the current line -// fold.lineRange = fold.lineRange.lowerBound...lineInfo.lineNumber -// fold.range = NSRange(start: fold.range.location, end: lineInfo.providerInfo.rangeIndice) -// // move up -// currentFold = fold.parent -// } -// } -// -// currentDepth = lineInfo.providerInfo.depth -// } -// lines = self.getMoreLines( -// textView: textView, -// iterator: &iterator, -// lastDepth: currentDepth, -// foldProvider: foldProvider -// ) -// } -// -// // Clean up any hanging folds. -// while let fold = currentFold { -// fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount - 1 -// fold.range = NSRange(start: fold.range.location, end: textView.documentRange.length) -// currentFold = fold.parent -// } -// -// self.rangesPublisher.send(foldCache) -// } + workQueue.async { + guard let textView = self.textView, let foldProvider = self.foldProvider else { return } + var foldCache: [LineFoldStorage.RawFold] = [] + // Depth: Open range + var openFolds: [Int: LineFoldStorage.RawFold] = [:] + var currentDepth: Int = 0 + var iterator = textView.layoutManager.linesInRange(textView.documentRange) + + var lines = self.getMoreLines( + textView: textView, + iterator: &iterator, + previousDepth: currentDepth, + foldProvider: foldProvider + ) + while let lineChunk = lines { + for lineInfo in lineChunk where lineInfo.depth > 0 { + // 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.. [LineInfo]? { + ) -> [LineFoldProviderLineInfo]? { DispatchQueue.main.asyncAndWait { - var results: [LineInfo] = [] + var results: [LineFoldProviderLineInfo] = [] var count = 0 - var lastDepth = lastDepth + var previousDepth: Int = previousDepth while count < 50, let linePosition = iterator.next() { - guard let foldInfo = foldProvider.foldLevelAtLine( + let foldInfo = foldProvider.foldLevelAtLine( lineNumber: linePosition.index, lineRange: linePosition.range, - currentDepth: lastDepth, + previousDepth: previousDepth, text: textView.textStorage - ) else { - count += 1 - continue - } - results.append( - LineInfo( - lineNumber: linePosition.index, - providerInfo: foldInfo, - collapsed: false - ) ) + results.append(contentsOf: foldInfo) count += 1 - lastDepth = foldInfo.depth + previousDepth = foldInfo.max(by: { $0.depth < $1.depth })?.depth ?? previousDepth } if results.isEmpty && count == 0 { return nil diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift new file mode 100644 index 000000000..b4528806d --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -0,0 +1,124 @@ +// +// 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 { + typealias FoldIdentifier = UInt32 + + let id: FoldIdentifier + let depth: Int + let range: Range + var isCollapsed: Bool +} + +/// 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] = [], collapsedProvider: () -> Set = { [] }) { + self.store = RangeStore(documentLength: documentLength) + self.updateFolds(from: folds, collapsedProvider: collapsedProvider) + } + + 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 collapsedProvider: callback returning current collapsed ranges/depths + mutating func updateFolds(from rawFolds: [RawFold], collapsedProvider: () -> 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 + } + + // Determine which ranges are currently collapsed + let collapsedSet = collapsedProvider() + + // 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 = collapsedSet.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) + } + + /// 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 } + } + + /// Given a depth and a location, return the full original fold region + func fullFoldRegion(at location: Int, depth: Int) -> FoldRange? { + guard let elem = store.findValue(at: location), elem.depth == depth else { + return nil + } + return foldRanges[elem.id] + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index 3b409c2fd..d28985c6b 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -21,7 +21,7 @@ import Combine class LineFoldingModel: NSObject, NSTextStorageDelegate { /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` /// and ``FoldRange/subFolds``. - @Published var foldCache: Atomic<[FoldRange]> = Atomic([]) + @Published var foldCache: Atomic = Atomic(LineFoldStorage(documentLength: 0)) private var cacheLock = NSLock() private var calculator: LineFoldCalculator private var cancellable: AnyCancellable? @@ -39,9 +39,8 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { calculator.textChangedReceiver.send((.zero, 0)) } - func getFolds(in lineRange: ClosedRange) -> [FoldRange] { -// foldCache.withValue { $0.filter({ $0.lineRange.overlaps(lineRange) }) } - [] + func getFolds(in range: Range) -> [FoldRange] { + foldCache.withValue({ $0.folds(in: range) }) } func textStorage( @@ -53,6 +52,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { guard editedMask.contains(.editedCharacters) else { return } + foldCache.mutate({ $0.storageUpdated(editedRange: editedRange, changeInLength: delta) }) calculator.textChangedReceiver.send((editedRange, delta)) } @@ -67,9 +67,12 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { /// - 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)? { -// foldCache.withValue { foldCache in -// binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0, findDeepest: true) -// } - nil + guard let lineRange = textView?.layoutManager.textLineForIndex(lineNumber)?.range else { return nil } + return foldCache.withValue { foldCache in + guard let deepestFold = foldCache.folds(in: lineRange.intRange).max(by: { $0.depth < $1.depth }) else { + return nil + } + return (deepestFold, deepestFold.depth) + } } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift index d973a6483..40ecd262c 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -9,6 +9,12 @@ 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) { diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index 8253f6ce3..b5bc0a253 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -37,14 +37,13 @@ extension FoldingRibbonView { let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { return } - let lineRange = rangeStart.index...rangeEnd.index + let textRange = rangeStart.range.location.. - let depth: Int - } - static let width: CGFloat = 7.0 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.FoldIdentifier? = nil // swiftlint:disable:this redundant_optional_initialization var hoverAnimationTimer: Timer? @Invalidating(.display) var hoverAnimationProgress: CGFloat = 0.0 @@ -141,57 +136,59 @@ class FoldingRibbonView: NSView { super.mouseDown(with: event) return } -// if let attachment = model.textView?.layoutManager.attachments.getAttachmentsStartingIn(fold.range.range).first { -// model.textView?.layoutManager.attachments.remove(atOffset: attachment.range.location) -// fold.range.collapsed = false -// attachments.removeAll(where: { $0 === attachment.attachment }) -// } else { -// let placeholder = LineFoldPlaceholder() -// model.textView?.layoutManager.attachments.add(placeholder, for: fold.range.range) -// attachments.append(placeholder) + if let attachment = model.textView?.layoutManager.attachments + .getAttachmentsStartingIn(NSRange(fold.range.range)) + .filter({ $0.attachment is LineFoldPlaceholder }) + .first { + model.textView?.layoutManager.attachments.remove(atOffset: attachment.range.location) +// fold.range.isCollapsed = false + attachments.removeAll(where: { $0 === attachment.attachment }) + } else { + let placeholder = LineFoldPlaceholder(fold: fold.range) + model.textView?.layoutManager.attachments.add(placeholder, for: NSRange(fold.range.range)) + attachments.append(placeholder) // fold.range.collapsed = true -// } + } model.textView?.needsLayout = true } override func mouseMoved(with event: NSEvent) { -// let pointInView = convert(event.locationInWindow, from: nil) -// guard let lineNumber = model.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 { -// return -// } -// 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 -// } -// -// // Don't animate these -// hoverAnimationProgress = 1.0 -// hoveringFold = newHoverRange + let pointInView = convert(event.locationInWindow, from: nil) + guard let lineNumber = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index, + let fold = model.getCachedFoldAt(lineNumber: lineNumber) else { + hoverAnimationProgress = 0.0 + hoveringFold = nil + return + } + + guard fold.range.id != hoveringFold else { + return + } + 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 = fold.range.id + + 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 + } + + // Don't animate these + hoverAnimationProgress = 1.0 + hoveringFold = fold.range.id } override func mouseExited(with event: NSEvent) { 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/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift index fce1d62a8..b755979d1 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift @@ -9,51 +9,62 @@ import Testing @testable import CodeEditSourceEditor struct LineFoldStorageTests { - var storage = LineFoldStorage( - foldDepths: [ - (1..<9, 1), - (2..<8, 2), - (5..<6, 3) - ], - documentLength: 10 - ) - - @Test - func findDepthAtIndexes() { - #expect(storage.depth(at: 0) == nil) - #expect(storage.depth(at: 1) == 1) - #expect(storage.depth(at: 2) == 2) - #expect(storage.depth(at: 5) == 3) - #expect(storage.depth(at: 6) == 2) - #expect(storage.depth(at: 8) == 1) - #expect(storage.depth(at: 9) == nil) + // 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 getDijointRunsForDepth() { - #expect( - storage.collectRuns(forDeepestFoldAt: 5) - == LineFoldStorage.FoldRunInfo(depth: 3, collapsed: false, runs: [5..<6]) - ) - - #expect( - storage.collectRuns(forDeepestFoldAt: 2) - == LineFoldStorage.FoldRunInfo(depth: 2, collapsed: false, runs: [2..<5, 6..<9]) - ) - - #expect( - storage.collectRuns(forDeepestFoldAt: 1) - == LineFoldStorage.FoldRunInfo(depth: 1, collapsed: false, runs: [1..<2, 8..<9]) - ) + @Test("Empty storage has no folds") + func emptyStorage() { + let storage = LineFoldStorage(documentLength: 50) + let folds = storage.folds(in: 0..<50) + #expect(folds.isEmpty) } - @Test - mutating func toggleCollapse() { - storage.toggleCollapse(at: 1) + @Test("updateFolds populates folds with correct depth and range") + 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) { [] } - #expect( - storage.collectRuns(forDeepestFoldAt: 1) - == LineFoldStorage.FoldRunInfo(depth: 1, collapsed: true, runs: [1..<2, 8..<9]) - ) + let folds = storage.folds(in: 0..<20) + #expect(folds.count == 2) + #expect(folds[0].depth == 1 && folds[0].range == 0..<5 && folds[0].collapsed == false) + #expect(folds[1].depth == 2 && folds[1].range == 5..<10 && folds[1].collapsed == false) + } + + @Test("updateFolds carries over collapse state via collapsedProvider") + func preserveCollapseState() { + var storage = LineFoldStorage(documentLength: 15) + let raw = [LineFoldStorage.RawFold(depth: 1, range: 0..<5)] + // First pass: no collapsed + storage.updateFolds(from: raw) { [] } + #expect(storage.folds(in: 0..<15).first?.collapsed == false) + + // Second pass: provider marks depth=1, start=0 as collapsed + storage.updateFolds(from: raw) { + collapsedSet((1, 0)) + } + #expect(storage.folds(in: 0..<15).first?.collapsed == true) + } + + @Test("FoldRegion IDs remain stable between identical updates") + func stableIDsBetweenUpdates() { + var storage = LineFoldStorage(documentLength: 30) + let raw = [LineFoldStorage.RawFold(depth: 2, range: 10..<20)] + + storage.updateFolds(from: raw) { [] } + let initial = storage.fullFoldRegion(at: 10, depth: 2)!.id + + // Perform update again with identical raw folds + storage.updateFolds(from: raw) { [] } + let subsequent = storage.fullFoldRegion(at: 10, depth: 2)!.id + + #expect(initial == subsequent) } } From 3b13a306b77ef04a7665ee3189b8660c9280f304 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 3 Jun 2025 09:23:43 -0500 Subject: [PATCH 23/33] Move to Swift Concurrency with Async Streams --- .../TextViewController+Lifecycle.swift | 2 +- .../Gutter/GutterView.swift | 6 +- .../IndentationLineFoldProvider.swift | 32 ++- .../FoldProviders/LineFoldProvider.swift | 2 +- .../Model/LineFoldCalculator.swift | 200 +++++++++--------- .../LineFolding/Model/LineFoldStorage.swift | 24 ++- .../LineFolding/Model/LineFoldingModel.swift | 49 +++-- .../View/FoldingRibbonView+Draw.swift | 45 +++- .../LineFolding/View/FoldingRibbonView.swift | 56 +++-- .../LineFoldStorageTests.swift | 16 +- 10 files changed, 260 insertions(+), 172 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 8d19f0662..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() diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 4bdf0a2e7..76d9152ee 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -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/LineFolding/FoldProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift index af1463d1e..d200666ac 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift @@ -6,8 +6,19 @@ // import AppKit +import TextStory import CodeEditTextView +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) + } +} + 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: .whitespacesWithoutNewlines)?.length, leadingIndent != lineRange.length else { return [] } - var foldIndicators: [LineFoldProviderLineInfo] = [] - if leadingIndent < previousDepth { + 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: leadingIndent)) + foldIndicators.append( + .endFold( + rangeEnd: lineRange.location + leadingIndent, + newDepth: leadingDepth + ) + ) } // Check if the next line has more indent @@ -45,7 +62,12 @@ final class IndentationLineFoldProvider: LineFoldProvider { } if nextIndent > leadingIndent, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) { - foldIndicators.append(.startFold(rangeStart: trailingWhitespace.location, newDepth: nextIndent)) + 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 index 077b71a3a..e8b803ccd 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift @@ -36,6 +36,6 @@ protocol LineFoldProvider: AnyObject { lineNumber: Int, lineRange: NSRange, previousDepth: Int, - text: NSTextStorage + controller: TextViewController ) -> [LineFoldProviderLineInfo] } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index b42ae1d53..550a78717 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -14,132 +14,140 @@ import Combine /// `LineFoldCalculator` observes text edits and rebuilds fold regions asynchronously. /// Fold information is emitted via `rangesPublisher`. /// Notify the calculator it should re-calculate -class LineFoldCalculator { +actor LineFoldCalculator { weak var foldProvider: LineFoldProvider? - weak var textView: TextView? + weak var controller: TextViewController? - var rangesPublisher = CurrentValueSubject(.init(documentLength: 0)) + var valueStream: AsyncStream - private let workQueue = DispatchQueue.global(qos: .default) + private var valueStreamContinuation: AsyncStream.Continuation + private var textChangedTask: Task? - var textChangedReceiver = PassthroughSubject<(NSRange, Int), Never>() - private var textChangedCancellable: AnyCancellable? - - init(foldProvider: LineFoldProvider?, textView: TextView) { + init( + foldProvider: LineFoldProvider?, + controller: TextViewController, + textChangedStream: AsyncStream<(NSRange, Int)> + ) { self.foldProvider = foldProvider - self.textView = textView + self.controller = controller + (valueStream, valueStreamContinuation) = AsyncStream.makeStream() + Task { await listenToTextChanges(textChangedStream: textChangedStream) } + } + + deinit { + textChangedTask?.cancel() + } - textChangedCancellable = textChangedReceiver - .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) - .sink { edit in - self.buildFoldsForDocument(afterEditIn: edit.0, delta: edit.1) + 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) { - workQueue.async { - guard let textView = self.textView, let foldProvider = self.foldProvider else { return } - var foldCache: [LineFoldStorage.RawFold] = [] - // Depth: Open range - var openFolds: [Int: LineFoldStorage.RawFold] = [:] - var currentDepth: Int = 0 - var iterator = textView.layoutManager.linesInRange(textView.documentRange) + 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 iterator = await controller.textView.layoutManager.linesInRange(documentRange) + + var lines = await self.getMoreLines( + controller: controller, + iterator: &iterator, + previousDepth: currentDepth, + foldProvider: foldProvider + ) + while let lineChunk = lines { + for lineInfo in lineChunk where lineInfo.depth > 0 { + // 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.. 0 { - // 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.. [LineFoldProviderLineInfo]? { - DispatchQueue.main.asyncAndWait { - var results: [LineFoldProviderLineInfo] = [] - var count = 0 - var previousDepth: Int = previousDepth - while count < 50, let linePosition = iterator.next() { - let foldInfo = foldProvider.foldLevelAtLine( - lineNumber: linePosition.index, - lineRange: linePosition.range, - previousDepth: previousDepth, - text: textView.textStorage - ) - 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 + var results: [LineFoldProviderLineInfo] = [] + var count = 0 + var previousDepth: Int = previousDepth + while count < 50, let linePosition = iterator.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 index b4528806d..7877e9de2 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -8,7 +8,7 @@ import _RopeModule import Foundation /// Represents a single fold region with stable identifier and collapse state -struct FoldRange: Sendable { +struct FoldRange: Sendable, Equatable { typealias FoldIdentifier = UInt32 let id: FoldIdentifier @@ -17,6 +17,14 @@ struct FoldRange: Sendable { var isCollapsed: Bool } +/// Represents a single fold run with stable identifier and collapse state +struct FoldRun: Sendable { + let id: FoldRange.FoldIdentifier + let depth: Int + let range: Range + let isCollapsed: Bool +} + /// Sendable data model for code folding using RangeStore struct LineFoldStorage: Sendable { /// A temporary fold representation without stable ID @@ -112,6 +120,20 @@ struct LineFoldStorage: Sendable { } return result.sorted { $0.range.lowerBound < $1.range.lowerBound } +// let runs = store.runs(in: queryRange.clamped(to: 0.. = Atomic(LineFoldStorage(documentLength: 0)) - private var cacheLock = NSLock() + var foldCache: LineFoldStorage = LineFoldStorage(documentLength: 0) private var calculator: LineFoldCalculator - private var cancellable: AnyCancellable? - weak var textView: TextView? + private var textChangedStream: AsyncStream<(NSRange, Int)> + private var textChangedStreamContinuation: AsyncStream<(NSRange, Int)>.Continuation + private var cacheListenTask: Task? - init(textView: TextView, foldProvider: LineFoldProvider?) { - self.textView = textView - self.calculator = LineFoldCalculator(foldProvider: foldProvider, textView: textView) + weak var controller: TextViewController? + + init(controller: TextViewController, foldView: FoldingRibbonView, foldProvider: LineFoldProvider?) { + self.controller = controller + (textChangedStream, textChangedStreamContinuation) = AsyncStream<(NSRange, Int)>.makeStream() + self.calculator = LineFoldCalculator( + foldProvider: foldProvider, + controller: controller, + textChangedStream: textChangedStream + ) super.init() - textView.addStorageDelegate(self) - cancellable = self.calculator.rangesPublisher.receive(on: RunLoop.main).sink { newFolds in - self.foldCache.mutate { $0 = newFolds } + controller.textView.addStorageDelegate(self) + + cacheListenTask = Task { @MainActor [weak foldView] in + for await newFolds in await calculator.valueStream { + foldCache = newFolds + foldView?.needsDisplay = true + } } - calculator.textChangedReceiver.send((.zero, 0)) + textChangedStreamContinuation.yield((.zero, 0)) } func getFolds(in range: Range) -> [FoldRange] { - foldCache.withValue({ $0.folds(in: range) }) + foldCache.folds(in: range) } func textStorage( @@ -52,8 +63,8 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { guard editedMask.contains(.editedCharacters) else { return } - foldCache.mutate({ $0.storageUpdated(editedRange: editedRange, changeInLength: delta) }) - calculator.textChangedReceiver.send((editedRange, delta)) + foldCache.storageUpdated(editedRange: editedRange, changeInLength: delta) + textChangedStreamContinuation.yield((editedRange, delta)) } /// Finds the deepest cached depth of the fold for a line number. @@ -67,12 +78,10 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { /// - 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)? { - guard let lineRange = textView?.layoutManager.textLineForIndex(lineNumber)?.range else { return nil } - return foldCache.withValue { foldCache in - guard let deepestFold = foldCache.folds(in: lineRange.intRange).max(by: { $0.depth < $1.depth }) else { - return nil - } - return (deepestFold, deepestFold.depth) + guard let lineRange = controller?.textView.layoutManager.textLineForIndex(lineNumber)?.range else { return nil } + guard let deepestFold = foldCache.folds(in: lineRange.intRange).max(by: { $0.depth < $1.depth }) else { + return nil } + return (deepestFold, deepestFold.depth) } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index b5bc0a253..ed06a968c 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -25,7 +25,7 @@ extension FoldingRibbonView { override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext, - let layoutManager = model.textView?.layoutManager else { + let layoutManager = model?.controller?.textView.layoutManager else { return } @@ -39,11 +39,24 @@ extension FoldingRibbonView { } let textRange = rangeStart.range.location.. Date: Tue, 3 Jun 2025 10:06:49 -0500 Subject: [PATCH 24/33] Remove some comments --- .../IndentationLineFoldProvider.swift | 4 +- .../Model/LineFoldCalculator.swift | 2 +- .../View/FoldingRibbonView+Draw.swift | 44 +++++++++---------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift index d200666ac..e039e922c 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift @@ -13,7 +13,7 @@ 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) } @@ -37,7 +37,7 @@ final class IndentationLineFoldProvider: LineFoldProvider { controller: TextViewController ) -> [LineFoldProviderLineInfo] { let text = controller.textView.textStorage.string as NSString - guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesWithoutNewlines)?.length, + guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesAndNewlines)?.length, leadingIndent != lineRange.length else { return [] } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 550a78717..7e80a9b64 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -66,7 +66,7 @@ actor LineFoldCalculator { foldProvider: foldProvider ) while let lineChunk = lines { - for lineInfo in lineChunk where lineInfo.depth > 0 { + for lineInfo in lineChunk { // Start a new fold, going deeper to a new depth. if lineInfo.depth > currentDepth { let newFold = LineFoldStorage.RawFold( diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index ed06a968c..ea55e02a6 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -39,7 +39,23 @@ extension FoldingRibbonView { } let textRange = rangeStart.range.location..) -> [FoldRange] { 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.. Date: Tue, 3 Jun 2025 10:57:37 -0500 Subject: [PATCH 25/33] Fix Drawing Ordering, Use Attachment Ranges --- .../Model/LineFoldCalculator.swift | 14 ++++--- .../LineFolding/Model/LineFoldStorage.swift | 41 +++++-------------- .../LineFolding/Model/LineFoldingModel.swift | 14 +++++-- .../View/FoldingRibbonView+Draw.swift | 11 ++++- .../LineFolding/View/FoldingRibbonView.swift | 25 ++++++----- 5 files changed, 54 insertions(+), 51 deletions(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 7e80a9b64..d9a2b242a 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -57,7 +57,7 @@ actor LineFoldCalculator { // Depth: Open range var openFolds: [Int: LineFoldStorage.RawFold] = [:] var currentDepth: Int = 0 - var iterator = await controller.textView.layoutManager.linesInRange(documentRange) + var iterator = await controller.textView.layoutManager.lineStorage.makeIterator() var lines = await self.getMoreLines( controller: controller, @@ -109,9 +109,11 @@ actor LineFoldCalculator { let attachments = await controller.textView.layoutManager.attachments .getAttachmentsOverlapping(documentRange) - .compactMap { $0.attachment as? LineFoldPlaceholder } - .map { - LineFoldStorage.DepthStartPair(depth: $0.fold.depth, start: $0.fold.range.lowerBound) + .compactMap { attachmentBox -> 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( @@ -119,7 +121,7 @@ actor LineFoldCalculator { by: { $0.range.upperBound < $1.range.upperBound } )?.range.upperBound ?? documentRange.length, folds: foldCache.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }), - collapsedProvider: { Set(attachments) } + collapsedRanges: Set(attachments) ) valueStreamContinuation.yield(storage) } @@ -127,7 +129,7 @@ actor LineFoldCalculator { @MainActor private func getMoreLines( controller: TextViewController, - iterator: inout TextLayoutManager.RangeIterator, + iterator: inout TextLineStorage.TextLineStorageIterator, previousDepth: Int, foldProvider: LineFoldProvider ) -> [LineFoldProviderLineInfo]? { diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift index 7877e9de2..25a674673 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -17,14 +17,6 @@ struct FoldRange: Sendable, Equatable { var isCollapsed: Bool } -/// Represents a single fold run with stable identifier and collapse state -struct FoldRun: Sendable { - let id: FoldRange.FoldIdentifier - let depth: Int - let range: Range - let isCollapsed: Bool -} - /// Sendable data model for code folding using RangeStore struct LineFoldStorage: Sendable { /// A temporary fold representation without stable ID @@ -51,9 +43,9 @@ struct LineFoldStorage: Sendable { private var foldRanges: [FoldRange.FoldIdentifier: FoldRange] = [:] /// Initialize with the full document length - init(documentLength: Int, folds: [RawFold] = [], collapsedProvider: () -> Set = { [] }) { + init(documentLength: Int, folds: [RawFold] = [], collapsedRanges: Set = []) { self.store = RangeStore(documentLength: documentLength) - self.updateFolds(from: folds, collapsedProvider: collapsedProvider) + self.updateFolds(from: folds, collapsedRanges: collapsedRanges) } private mutating func nextFoldId() -> FoldRange.FoldIdentifier { @@ -63,17 +55,14 @@ struct LineFoldStorage: Sendable { /// Replace all fold data from raw folds, preserving collapse state via callback /// - Parameter rawFolds: newly computed folds (depth + range) - /// - Parameter collapsedProvider: callback returning current collapsed ranges/depths - mutating func updateFolds(from rawFolds: [RawFold], collapsedProvider: () -> Set) { + /// - 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 } - // Determine which ranges are currently collapsed - let collapsedSet = collapsedProvider() - // Build new regions foldRanges.removeAll(keepingCapacity: true) store = RangeStore(documentLength: store.length) @@ -85,7 +74,7 @@ struct LineFoldStorage: Sendable { let id = prior?.id ?? nextFoldId() let wasCollapsed = prior?.isCollapsed ?? false // override collapse if provider says so - let isCollapsed = collapsedSet.contains(key) || wasCollapsed + let isCollapsed = collapsedRanges.contains(key) || wasCollapsed let fold = FoldRange(id: id, depth: raw.depth, range: raw.range, isCollapsed: isCollapsed) foldRanges[id] = fold @@ -99,6 +88,12 @@ struct LineFoldStorage: Sendable { 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.. (range: FoldRange, depth: Int)? { + 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: { $0.depth < $1.depth }) else { + 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, deepestFold.depth) + return deepestFold } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index ea55e02a6..07a8a8bc2 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -40,7 +40,15 @@ extension FoldingRibbonView { let textRange = rangeStart.range.location.. Date: Tue, 3 Jun 2025 13:39:21 -0500 Subject: [PATCH 26/33] Add Straight Line When Adjacent --- Package.resolved | 9 -- .../NSBezierPath+RoundedCorners.swift | 111 ++++++++++++++ .../NSEdgeInsets+Equatable.swift | 0 .../NSEdgeInsets+Helpers.swift | 0 .../{ => NSFont}/NSFont+LineHeight.swift | 0 .../{ => NSFont}/NSFont+RulerFont.swift | 0 .../LineFolding/Model/LineFoldStorage.swift | 4 + .../View/FoldingRibbonView+Draw.swift | 141 +++++++++++++----- .../LineFolding/View/FoldingRibbonView.swift | 14 +- 9 files changed, 228 insertions(+), 51 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift rename Sources/CodeEditSourceEditor/Extensions/{ => NSEdgeInsets}/NSEdgeInsets+Equatable.swift (100%) rename Sources/CodeEditSourceEditor/Extensions/{ => NSEdgeInsets}/NSEdgeInsets+Helpers.swift (100%) rename Sources/CodeEditSourceEditor/Extensions/{ => NSFont}/NSFont+LineHeight.swift (100%) rename Sources/CodeEditSourceEditor/Extensions/{ => NSFont}/NSFont+RulerFont.swift (100%) 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/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift new file mode 100644 index 000000000..f8d57c5c4 --- /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) + } + + 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/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift index 25a674673..33ae02e68 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -15,6 +15,10 @@ struct FoldRange: Sendable, Equatable { 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 diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index 07a8a8bc2..9863d83a7 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -9,20 +9,52 @@ 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 + 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 ) } } + 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 { @@ -38,19 +70,21 @@ extension FoldingRibbonView { return } let textRange = rangeStart.range.location..) -> [FoldRange] { + private func getDrawingFolds( + forTextRange textRange: Range, + 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 @@ -78,7 +115,14 @@ extension FoldingRibbonView { } } - return folds + return folds.compactMap { fold in + guard let startLine = layoutManager.textLineForOffset(fold.range.lowerBound), + let endLine = layoutManager.textLineForOffset(fold.range.upperBound) else { + return nil + } + + return DrawingFoldInfo(fold: fold, startLine: startLine, endLine: endLine) + } } /// Draw a single fold marker for a fold. @@ -86,28 +130,23 @@ extension FoldingRibbonView { /// Ensure the correct fill color is set on the drawing context before calling. /// /// - Parameters: - /// - fold: The fold to draw. + /// - 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. /// - context: The drawing context to use. /// - layoutManager: A layout manager used to retrieve position information for lines. private func drawFoldMarker( - _ fold: FoldRange, + _ foldInfo: DrawingFoldInfo, + foldCaps: FoldCapInfo, in context: CGContext, using layoutManager: TextLayoutManager ) { - guard let minYPosition = layoutManager.textLineForOffset(fold.range.lowerBound)?.yPos, - let maxPosition = layoutManager.textLineForOffset(fold.range.upperBound) else { - return - } - - let maxYPosition = maxPosition.yPos + maxPosition.height + let minYPosition = foldInfo.startLine.yPos + let maxYPosition = foldInfo.endLine.yPos + foldInfo.endLine.height - if fold.isCollapsed { + if foldInfo.fold.isCollapsed { drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context) - } else if let hoveringFold, - hoveringFold.depth == fold.depth, - NSRange(hoveringFold.range).intersection(NSRange(fold.range)) == NSRange(hoveringFold.range) { + } else if let hoveringFold, hoveringFold.isHoveringEqual(foldInfo.fold) { drawHoveredFold( minYPosition: minYPosition, maxYPosition: maxYPosition, @@ -115,7 +154,8 @@ extension FoldingRibbonView { ) } else { drawNestedFold( - fold: fold, + foldInfo: foldInfo, + foldCaps: foldCaps, minYPosition: minYPosition, maxYPosition: maxYPosition, in: context @@ -204,26 +244,37 @@ extension FoldingRibbonView { } private func drawNestedFold( - fold: FoldRange, + foldInfo: DrawingFoldInfo, + foldCaps: FoldCapInfo, minYPosition: CGFloat, maxYPosition: CGFloat, in context: CGContext ) { context.saveGState() - 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) + 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, + capTop: foldCaps.foldNeedsTopCap(foldInfo), + capBottom: foldCaps.foldNeedsBottomCap(foldInfo), + cornerRadius: radius + ) context.setFillColor(markerColor) context.addPath(roundedRect.cgPathFallback) context.drawPath(using: .fill) // Add small white line if we're overlapping with other markers - if fold.depth != 0 { + if foldInfo.fold.depth != 0 { drawOutline( + foldInfo: foldInfo, + foldCaps: foldCaps, + originalPath: roundedRect.cgPathFallback, minYPosition: minYPosition, maxYPosition: maxYPosition, - originalPath: roundedRect, in: context ) } @@ -241,19 +292,31 @@ extension FoldingRibbonView { /// - originalPath: The original bezier path for the rounded rectangle. /// - context: The context to draw in. private func drawOutline( + foldInfo: DrawingFoldInfo, + foldCaps: FoldCapInfo, + originalPath: CGPath, 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 plainRect = foldCaps.adjustFoldRect( + using: foldInfo, + rect: NSRect(x: -0.5, y: minYPosition, width: frame.width + 1.0, height: maxYPosition - minYPosition) + ) + 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.cgPathFallback) + combined.addPath(originalPath) context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) context.addPath(combined) diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index 7e202cafe..4cb60c4d8 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -135,9 +135,8 @@ class FoldingRibbonView: NSView { super.mouseDown(with: event) return } - if let attachment = model?.controller?.textView?.layoutManager.attachments - .getAttachmentsStartingIn(NSRange(fold.range)) - .filter({ $0.attachment is LineFoldPlaceholder && firstLineInFold.range.contains($0.range.location) }).first { + + if let attachment = findAttachmentFor(fold: fold, firstLineRange: firstLineInFold.range) { layoutManager.attachments.remove(atOffset: attachment.range.location) attachments.removeAll(where: { $0 === attachment.attachment }) } else { @@ -150,6 +149,14 @@ class FoldingRibbonView: NSView { 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) @@ -193,6 +200,7 @@ class FoldingRibbonView: NSView { } override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) hoverAnimationProgress = 0.0 hoveringFold = nil } From aa89ce22cfee151dd804d10168846141d92c627f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:23:36 -0500 Subject: [PATCH 27/33] Clean Up, Add Tests --- .../Extensions/NSString+TextStory.swift | 19 ++ .../LineFolding/FoldingRibbonView+Draw.swift | 205 ------------------ .../IndentationLineFoldProvider.swift | 11 - .../FoldProviders/LineFoldProvider.swift | 1 + .../Model/LineFoldCalculator.swift | 1 - .../LineFolding/Model/LineFoldingModel.swift | 6 +- .../LineFolding/View/FoldingRibbonView.swift | 9 +- .../LineFoldStorageTests.swift | 12 +- .../LineFoldingModelTests.swift | 98 +++++---- 9 files changed, 87 insertions(+), 275 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.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/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/LineFolding/FoldProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift index e039e922c..84626c971 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift @@ -6,19 +6,8 @@ // import AppKit -import TextStory import CodeEditTextView -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) - } -} - final class IndentationLineFoldProvider: LineFoldProvider { func indentLevelAtLine(substring: NSString) -> Int? { for idx in 0.. @@ -30,7 +30,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { weak var controller: TextViewController? - init(controller: TextViewController, foldView: FoldingRibbonView, foldProvider: LineFoldProvider?) { + init(controller: TextViewController, foldView: NSView, foldProvider: LineFoldProvider?) { self.controller = controller (textChangedStream, textChangedStreamContinuation) = AsyncStream<(NSRange, Int)>.makeStream() self.calculator = LineFoldCalculator( diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index 4cb60c4d8..e83790d68 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -10,13 +10,14 @@ import AppKit import CodeEditTextView import Combine -#warning("Replace before release") -private let demoFoldProvider = IndentationLineFoldProvider() - /// Displays the code folding ribbon in the ``GutterView``. /// /// This view draws its contents class FoldingRibbonView: NSView { + +#warning("Replace before release") + private static let demoFoldProvider = IndentationLineFoldProvider() + static let width: CGFloat = 7.0 var model: LineFoldingModel? @@ -92,7 +93,7 @@ class FoldingRibbonView: NSView { self.model = LineFoldingModel( controller: controller, foldView: self, - foldProvider: foldProvider ?? demoFoldProvider + foldProvider: foldProvider ?? Self.demoFoldProvider ) } diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift index 3356b2735..5ef4d8e0d 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift @@ -30,7 +30,7 @@ struct LineFoldStorageTests { LineFoldStorage.RawFold(depth: 1, range: 0..<5), LineFoldStorage.RawFold(depth: 2, range: 5..<10) ] - storage.updateFolds(from: raw) { [] } + storage.updateFolds(from: raw, collapsedRanges: []) let folds = storage.folds(in: 0..<20) #expect(folds.count == 2) @@ -43,13 +43,11 @@ struct LineFoldStorageTests { var storage = LineFoldStorage(documentLength: 15) let raw = [LineFoldStorage.RawFold(depth: 1, range: 0..<5)] // First pass: no collapsed - storage.updateFolds(from: raw) { [] } + 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) { - collapsedSet((1, 0)) - } + storage.updateFolds(from: raw, collapsedRanges: collapsedSet((1, 0))) #expect(storage.folds(in: 0..<15).first?.isCollapsed == true) } @@ -58,11 +56,11 @@ struct LineFoldStorageTests { var storage = LineFoldStorage(documentLength: 30) let raw = [LineFoldStorage.RawFold(depth: 2, range: 10..<20)] - storage.updateFolds(from: raw) { [] } + storage.updateFolds(from: raw, collapsedRanges: []) let initial = storage.fullFoldRegion(at: 10, depth: 2)!.id // Perform update again with identical raw folds - storage.updateFolds(from: raw) { [] } + storage.updateFolds(from: raw, collapsedRanges: []) let subsequent = storage.fullFoldRegion(at: 10, depth: 2)!.id #expect(initial == subsequent) diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift index e5210bb97..168feab50 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -12,48 +12,58 @@ import CodeEditTextView @MainActor struct LineFoldingModelTests { -// /// Makes a fold pattern that increases until halfway through the document then goes back to zero. -// class HillPatternFoldProvider: LineFoldProvider { -// func foldLevelAtLine( -// lineNumber: Int, -// lineRange: NSRange, -// currentDepth: Int, -// text: NSTextStorage -// ) -> CodeEditSourceEditor.LineFoldProviderLineInfo? { -// let halfLineCount = (layoutManager.lineCount / 2) - 1 -// -// return if lineNumber > halfLineCount { -// layoutManager.lineCount - 2 - lineNumber -// } else { -// lineNumber -// } -// } -// } -// -// let textView: TextView -// let model: LineFoldingModel -// -// init() { -// 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 { -// let provider = HillPatternFoldProvider() -// model.foldProvider = provider -// -// model.buildFoldsForDocument() -// -// 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) -// } + /// Makes a fold pattern that increases until halfway through the document then goes back to zero. + @MainActor + class HillPatternFoldProvider: LineFoldProvider { + func foldLevelAtLine( + lineNumber: Int, + lineRange: NSRange, + previousDepth: Int, + controller: TextViewController + ) -> [LineFoldProviderLineInfo] { + let halfLineCount = (controller.textView.layoutManager.lineCount / 2) - 1 + + return if lineNumber > halfLineCount { + [ + .startFold( + rangeStart: lineRange.location, + newDepth: controller.textView.layoutManager.lineCount - 2 - lineNumber + ) + ] + } else { + [ + .endFold(rangeEnd: lineRange.location, newDepth: lineNumber) + ] + } + } + } + + let controller: TextViewController + let textView: TextView + + init() { + 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)) + } + + /// 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() async throws { + let provider = HillPatternFoldProvider() + let model = LineFoldingModel(controller: controller, foldView: NSView(), foldProvider: provider) + + var cacheUpdated = model.$foldCache.values.makeAsyncIterator() + _ = await cacheUpdated.next() + _ = await cacheUpdated.next() + + let fold = try #require(model.getFolds(in: 0..<6).first) + #expect(fold.range == 2..<10) + #expect(fold.depth == 1) + #expect(fold.isCollapsed == false) + } } From fb70a5821227b3ac7bf83cbf621e3c5fa2adfd45 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:33:53 -0500 Subject: [PATCH 28/33] Fix Lint Errors, Clean Up Calculator --- .../NSBezierPath+RoundedCorners.swift | 2 +- .../Model/LineFoldCalculator.swift | 90 +++++++++++-------- .../View/FoldingRibbonView+Draw.swift | 62 ++++--------- .../View/FoldingRibbonView+FoldCapInfo.swift | 50 +++++++++++ .../RangeStore/RangeStore+FindIndex.swift | 2 +- 5 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift index f8d57c5c4..09156caea 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift @@ -24,6 +24,7 @@ extension NSBezierPath { 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() @@ -95,7 +96,6 @@ extension NSBezierPath { close() } - convenience init(roundingRect: CGRect, capTop: Bool, capBottom: Bool, cornerRadius radius: CGFloat) { switch (capTop, capBottom) { case (true, true): diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 27ba8e521..275f0fe7d 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -56,15 +56,13 @@ actor LineFoldCalculator { // Depth: Open range var openFolds: [Int: LineFoldStorage.RawFold] = [:] var currentDepth: Int = 0 - var iterator = await controller.textView.layoutManager.lineStorage.makeIterator() - - var lines = await self.getMoreLines( + var lineIterator = await ChunkedLineIterator( controller: controller, - iterator: &iterator, - previousDepth: currentDepth, - foldProvider: foldProvider + foldProvider: foldProvider, + textIterator: await controller.textView.layoutManager.lineStorage.makeIterator() ) - while let lineChunk = lines { + + for await lineChunk in lineIterator { for lineInfo in lineChunk { // Start a new fold, going deeper to a new depth. if lineInfo.depth > currentDepth { @@ -88,12 +86,6 @@ actor LineFoldCalculator { currentDepth = lineInfo.depth } - lines = await self.getMoreLines( - controller: controller, - iterator: &iterator, - previousDepth: currentDepth, - foldProvider: foldProvider - ) } // Clean up any hanging folds. @@ -106,6 +98,14 @@ actor LineFoldCalculator { ) } + await yieldNewStorage(newFolds: foldCache, controller: controller, documentRange: documentRange) + } + + private func yieldNewStorage( + newFolds: [LineFoldStorage.RawFold], + controller: TextViewController, + documentRange: NSRange + ) async { let attachments = await controller.textView.layoutManager.attachments .getAttachmentsOverlapping(documentRange) .compactMap { attachmentBox -> LineFoldStorage.DepthStartPair? in @@ -116,39 +116,55 @@ actor LineFoldCalculator { } let storage = LineFoldStorage( - documentLength: foldCache.max( + documentLength: newFolds.max( by: { $0.range.upperBound < $1.range.upperBound } )?.range.upperBound ?? documentRange.length, - folds: foldCache.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }), + folds: newFolds.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }), collapsedRanges: Set(attachments) ) valueStreamContinuation.yield(storage) } @MainActor - private func getMoreLines( - controller: TextViewController, - iterator: inout TextLineStorage.TextLineStorageIterator, - previousDepth: Int, - foldProvider: LineFoldProvider - ) -> [LineFoldProviderLineInfo]? { - var results: [LineFoldProviderLineInfo] = [] - var count = 0 - var previousDepth: Int = previousDepth - while count < 50, let linePosition = iterator.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 + 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 } - if results.isEmpty && count == 0 { - return nil + + mutating func next() async -> [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 } - return results } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index 9863d83a7..1a0650291 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -9,46 +9,6 @@ import AppKit import CodeEditTextView 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 - ) - } - } - struct DrawingFoldInfo { let fold: FoldRange let startLine: TextLineStorage.TextLinePosition @@ -273,8 +233,7 @@ extension FoldingRibbonView { foldInfo: foldInfo, foldCaps: foldCaps, originalPath: roundedRect.cgPathFallback, - minYPosition: minYPosition, - maxYPosition: maxYPosition, + yPosition: minYPosition...maxYPosition, in: context ) } @@ -295,15 +254,19 @@ extension FoldingRibbonView { foldInfo: DrawingFoldInfo, foldCaps: FoldCapInfo, originalPath: CGPath, - minYPosition: CGFloat, - maxYPosition: CGFloat, + yPosition: ClosedRange, in context: CGContext ) { context.saveGState() let plainRect = foldCaps.adjustFoldRect( using: foldInfo, - rect: NSRect(x: -0.5, y: minYPosition, width: frame.width + 1.0, height: maxYPosition - minYPosition) + 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( @@ -318,7 +281,14 @@ extension FoldingRibbonView { combined.addPath(roundedRect.cgPathFallback) combined.addPath(originalPath) - context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) + 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) 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/RangeStore/RangeStore+FindIndex.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift index 51092f457..0c3438e09 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift @@ -12,7 +12,7 @@ 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. From 78cb70ab7a8e824120db1449331dfafdaee66c10 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:38:36 -0500 Subject: [PATCH 29/33] Document the async calculator --- .../Model/LineFoldCalculator.swift | 26 ++++++++++++++----- .../LineFolding/Model/LineFoldStorage.swift | 8 ------ .../LineFoldStorageTests.swift | 15 ----------- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 275f0fe7d..3a12022d0 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -11,8 +11,6 @@ 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. -/// Fold information is emitted via `rangesPublisher`. -/// Notify the calculator it should re-calculate actor LineFoldCalculator { weak var foldProvider: LineFoldProvider? weak var controller: TextViewController? @@ -21,7 +19,12 @@ 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. + /// - 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, @@ -36,7 +39,9 @@ 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)>) { textChangedTask = Task { for await edit in textChangedStream { @@ -100,7 +105,12 @@ 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. + /// - controller: The text controller used for range and attachment fetching. + /// - documentRange: The total range of the current document. private func yieldNewStorage( newFolds: [LineFoldStorage.RawFold], controller: TextViewController, @@ -125,6 +135,10 @@ actor LineFoldCalculator { 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 @@ -146,7 +160,7 @@ actor LineFoldCalculator { self } - mutating func next() async -> [LineFoldProviderLineInfo]? { + mutating func next() -> [LineFoldProviderLineInfo]? { var results: [LineFoldProviderLineInfo] = [] var count = 0 var previousDepth: Int = previousDepth diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift index 33ae02e68..52b5d78dd 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -120,12 +120,4 @@ struct LineFoldStorage: Sendable { return result.sorted { $0.range.lowerBound < $1.range.lowerBound } } - - /// Given a depth and a location, return the full original fold region - func fullFoldRegion(at location: Int, depth: Int) -> FoldRange? { - guard let elem = store.findValue(at: location), elem.depth == depth else { - return nil - } - return foldRanges[elem.id] - } } diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift index 5ef4d8e0d..44467bde2 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift @@ -50,19 +50,4 @@ struct LineFoldStorageTests { storage.updateFolds(from: raw, collapsedRanges: collapsedSet((1, 0))) #expect(storage.folds(in: 0..<15).first?.isCollapsed == true) } - - @Test - func stableIDsBetweenUpdates() { - var storage = LineFoldStorage(documentLength: 30) - let raw = [LineFoldStorage.RawFold(depth: 2, range: 10..<20)] - - storage.updateFolds(from: raw, collapsedRanges: []) - let initial = storage.fullFoldRegion(at: 10, depth: 2)!.id - - // Perform update again with identical raw folds - storage.updateFolds(from: raw, collapsedRanges: []) - let subsequent = storage.fullFoldRegion(at: 10, depth: 2)!.id - - #expect(initial == subsequent) - } } From 473a51385bb00e25ae48c19030b060eeb82340ad Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 4 Jun 2025 12:04:50 -0500 Subject: [PATCH 30/33] Collapsed Colors, Clean Up and Document --- .../View/FoldingRibbonView+Draw.swift | 28 ++++++- .../View/FoldingRibbonView+FoldCapInfo.swift | 16 ++-- .../LineFolding/View/FoldingRibbonView.swift | 80 +++++++++---------- 3 files changed, 76 insertions(+), 48 deletions(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index 1a0650291..d8e55870d 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -15,6 +15,8 @@ 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 { @@ -53,6 +55,20 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Get Drawing Folds + + /// Generates drawable fold info for a range of text. + /// + /// The fold storage intentionally does not store the full ranges of all folds at each interval. We may, for an + /// interval, find that we only receive fold information for depths > 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 @@ -123,6 +139,8 @@ extension FoldingRibbonView { } } + // MARK: - Collapsed Fold + private func drawCollapsedFold( minYPosition: CGFloat, maxYPosition: CGFloat, @@ -144,12 +162,12 @@ extension FoldingRibbonView { chevron.addLine(to: CGPoint(x: maxX, y: centerY)) chevron.addLine(to: CGPoint(x: minX, y: maxY)) - context.setStrokeColor(NSColor.secondaryLabelColor.cgColor) + 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,6 +175,8 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Hovered Fold + private func drawHoveredFold( minYPosition: CGFloat, maxYPosition: CGFloat, @@ -203,6 +223,8 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Nested Fold + private func drawNestedFold( foldInfo: DrawingFoldInfo, foldCaps: FoldCapInfo, @@ -241,6 +263,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. diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift index 1c766027a..5dd01d7aa 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift @@ -8,6 +8,8 @@ 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 struct FoldCapInfo { let startIndices: Set let endIndices: Set @@ -32,13 +34,15 @@ extension FoldingRibbonView { 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 + + var heightDelta: CGFloat = 0.0 + if capTop { + heightDelta -= fold.startLine.height / 2.0 + } + if capBottom { + 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..f34f88e67 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -10,6 +10,21 @@ import AppKit import CodeEditTextView import Combine +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() + } + } + } +} + /// Displays the code folding ribbon in the ``GutterView``. /// /// This view draws its contents @@ -33,52 +48,37 @@ class FoldingRibbonView: NSView { 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.secondaryLabelColor.cgColor override public var isFlipped: Bool { true From e0ceeb1a0875c5adf2203e7ad3ef9b47bcc69b80 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:29:27 -0500 Subject: [PATCH 31/33] Mask Collapsed Folds, Simplify Reasoning in Draw --- .../Extensions/NSColor+LightDark.swift | 23 ++++ .../Extensions/NSRect+Transform.swift | 19 +++ .../View/FoldingRibbonView+Draw.swift | 119 ++++++++++-------- .../View/FoldingRibbonView+FoldCapInfo.swift | 52 ++++++-- .../LineFolding/View/FoldingRibbonView.swift | 30 ++--- 5 files changed, 160 insertions(+), 83 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift 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..4558e29a2 --- /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: CGFloat = 0, y: CGFloat = 0, width: CGFloat = 0, height: CGFloat = 0) -> NSRect { + NSRect( + x: self.origin.x + x, + y: self.origin.y + y, + width: self.width + width, + height: self.height + height + ) + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index d8e55870d..d5ff311d8 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -19,21 +19,22 @@ extension FoldingRibbonView { 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.., 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 5dd01d7aa..3a77e86c5 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift @@ -9,22 +9,50 @@ 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 + /// 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( @@ -33,13 +61,17 @@ extension FoldingRibbonView { ) -> NSRect { let capTop = foldNeedsTopCap(fold) let capBottom = foldNeedsBottomCap(fold) - let yDelta = capTop ? fold.startLine.height / 2.0 : 0.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 { + if capTop && !collapsedEndIndices.contains(fold.startLine.index) { heightDelta -= fold.startLine.height / 2.0 } - if capBottom { + if capBottom && !collapsedStartIndices.contains(fold.endLine.index) { heightDelta -= fold.endLine.height / 2.0 } diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index f34f88e67..43aa90ee7 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -8,22 +8,6 @@ import Foundation import AppKit import CodeEditTextView -import Combine - -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() - } - } - } -} /// Displays the code folding ribbon in the ``GutterView``. /// @@ -40,6 +24,7 @@ class FoldingRibbonView: NSView { // 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 hoveringFoldMask: CGPath? var hoverAnimationTimer: Timer? @Invalidating(.display) var hoverAnimationProgress: CGFloat = 0.0 @@ -78,7 +63,10 @@ class FoldingRibbonView: NSView { ).cgColor @Invalidating(.display) - var foldedIndicatorChevronColor = NSColor.secondaryLabelColor.cgColor + 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 @@ -148,6 +136,7 @@ class FoldingRibbonView: NSView { model?.foldCache.toggleCollapse(forFold: fold) model?.controller?.textView.needsLayout = true + mouseMoved(with: event) } private func findAttachmentFor(fold: FoldRange, firstLineRange: NSRange) -> AnyTextAttachment? { @@ -165,9 +154,11 @@ 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 { + let fold = model?.getCachedFoldAt(lineNumber: lineNumber), + !fold.isCollapsed else { hoverAnimationProgress = 0.0 hoveringFold = nil + hoveringFoldMask = nil return } @@ -180,6 +171,7 @@ class FoldingRibbonView: NSView { if hoveringFold == nil { hoverAnimationProgress = 0.0 hoveringFold = fold + hoveringFoldMask = nil let duration: TimeInterval = 0.2 let startTime = CACurrentMediaTime() @@ -198,11 +190,13 @@ class FoldingRibbonView: NSView { // Don't animate these hoverAnimationProgress = 1.0 hoveringFold = fold + hoveringFoldMask = nil } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) hoverAnimationProgress = 0.0 hoveringFold = nil + hoveringFoldMask = nil } } From f4d4808f3b2bef12d07cc6781756b30044f22a50 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:09:38 -0500 Subject: [PATCH 32/33] Emphasize Brackets Surrounding Folds --- .../TextViewController+TextFormation.swift | 17 ---- .../Enums/BracketPairs.swift | 29 ++++++ .../NSBezierPath+RoundedCorners.swift | 2 + .../LineFolding/Model/LineFoldingModel.swift | 33 +++++++ .../Placeholder/LineFoldPlaceholder.swift | 28 ++++-- .../View/FoldingRibbonView+Draw.swift | 13 +-- .../LineFolding/View/FoldingRibbonView.swift | 88 +++++++++++-------- .../RangeStore/RangeStore.swift | 5 +- 8 files changed, 151 insertions(+), 64 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Enums/BracketPairs.swift 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..e3f7cc26e --- /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..3643b568f 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift @@ -22,6 +22,8 @@ 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/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index 08137c418..79ba9a71d 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -19,6 +19,8 @@ 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 +94,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 d5ff311d8..5968af107 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -130,7 +130,7 @@ extension FoldingRibbonView { maxYPosition: maxYPosition, in: context ) - } else if hoveringFold?.isHoveringEqual(foldInfo.fold) == true { + } else if hoveringFold.fold?.isHoveringEqual(foldInfo.fold) == true { drawHoveredFold( foldInfo: foldInfo, foldCaps: foldCaps, @@ -171,7 +171,8 @@ extension FoldingRibbonView { chevron.addLine(to: CGPoint(x: maxX, y: centerY)) chevron.addLine(to: CGPoint(x: minX, y: maxY)) - if let hoveringFoldMask, hoveringFoldMask.intersects(CGPath(rect: fillRect, transform: .none)) { + if let hoveringFoldMask = hoveringFold.foldMask, + hoveringFoldMask.intersects(CGPath(rect: fillRect, transform: .none)) { context.addPath(hoveringFoldMask) context.clip() } @@ -205,8 +206,8 @@ extension FoldingRibbonView { 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) @@ -220,7 +221,7 @@ extension FoldingRibbonView { let plainMaskRect = foldRect.transform(y: 1.0, height: -2.0) let roundedMaskRect = NSBezierPath(roundedRect: plainMaskRect, xRadius: Self.width / 2, yRadius: Self.width / 2) - hoveringFoldMask = roundedMaskRect.cgPathFallback + hoveringFold.foldMask = roundedMaskRect.cgPathFallback context.restoreGState() } @@ -240,7 +241,7 @@ extension FoldingRibbonView { 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) diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index 43aa90ee7..4da98a523 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -13,6 +13,18 @@ import CodeEditTextView /// /// This view draws its contents class FoldingRibbonView: NSView { + struct HoverAnimationDetails: Equatable { + var fold: FoldRange? = nil + 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() @@ -21,13 +33,8 @@ 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 hoveringFoldMask: CGPath? - var hoverAnimationTimer: Timer? @Invalidating(.display) - var hoverAnimationProgress: CGFloat = 0.0 + var hoveringFold: HoverAnimationDetails = .empty @Invalidating(.display) var backgroundColor: NSColor = NSColor.controlBackgroundColor @@ -129,7 +136,7 @@ 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) } @@ -156,47 +163,58 @@ class FoldingRibbonView: NSView { guard let lineNumber = model?.controller?.textView.layoutManager.textLineForPosition(pointInView.y)?.index, let fold = model?.getCachedFoldAt(lineNumber: lineNumber), !fold.isCollapsed else { - hoverAnimationProgress = 0.0 - hoveringFold = nil - hoveringFoldMask = nil + 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 - hoveringFoldMask = nil - + 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 - hoveringFoldMask = nil - } - - override func mouseExited(with event: NSEvent) { - super.mouseExited(with: event) - hoverAnimationProgress = 0.0 - hoveringFold = nil - hoveringFoldMask = 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. From 716ebaf4795c8ab6c758dc892046905d06dfe7c4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:59:02 -0500 Subject: [PATCH 33/33] lint:fix --- Sources/CodeEditSourceEditor/Enums/BracketPairs.swift | 2 +- .../CodeEditSourceEditor/Extensions/NSRect+Transform.swift | 6 +++--- .../LineFolding/Model/LineFoldCalculator.swift | 6 +++--- .../LineFolding/View/FoldingRibbonView+Draw.swift | 2 +- .../LineFolding/View/FoldingRibbonView.swift | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift index e3f7cc26e..bce82896c 100644 --- a/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift +++ b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift @@ -19,7 +19,7 @@ enum BracketPairs { ("[", "]"), ("(", ")") ] - + /// 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. diff --git a/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift index 4558e29a2..34be2a8e7 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift @@ -8,10 +8,10 @@ import AppKit extension NSRect { - func transform(x: CGFloat = 0, y: CGFloat = 0, width: CGFloat = 0, height: CGFloat = 0) -> NSRect { + func transform(x xVal: CGFloat = 0, y yVal: CGFloat = 0, width: CGFloat = 0, height: CGFloat = 0) -> NSRect { NSRect( - x: self.origin.x + x, - y: self.origin.y + y, + 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/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index 5968af107..3680d832f 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -57,7 +57,7 @@ extension FoldingRibbonView { } // MARK: - Get Drawing Folds - + /// Generates drawable fold info for a range of text. /// /// The fold storage intentionally does not store the full ranges of all folds at each interval. We may, for an diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index 4da98a523..54e86f4c9 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -14,7 +14,7 @@ import CodeEditTextView /// This view draws its contents class FoldingRibbonView: NSView { struct HoverAnimationDetails: Equatable { - var fold: FoldRange? = nil + var fold: FoldRange? var foldMask: CGPath? var timer: Timer? var progress: CGFloat = 0.0 @@ -178,13 +178,13 @@ class FoldingRibbonView: NSView { 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) {