diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index bd3a6ead7..5984ab0ea 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -26,12 +26,14 @@ struct ContentView: View { @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true @AppStorage("systemCursor") private var useSystemCursor: Bool = false + @State private var indentOption: IndentOption = .spaces(count: 4) @AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80 @AppStorage("showGutter") private var showGutter: Bool = true @AppStorage("showMinimap") private var showMinimap: Bool = true @AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false + @AppStorage("showFoldingRibbon") private var showFoldingRibbon: Bool = true @State private var invisibleCharactersConfig: InvisibleCharactersConfiguration = .empty @State private var warningCharacters: Set = [] @@ -86,6 +88,7 @@ struct ContentView: View { indentOption: $indentOption, reformatAtColumn: $reformatAtColumn, showReformattingGuide: $showReformattingGuide, + showFoldingRibbon: $showFoldingRibbon invisibles: $invisibleCharactersConfig, warningCharacters: $warningCharacters ) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 262f46756..597dff508 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -27,6 +27,7 @@ struct StatusBar: View { @Binding var indentOption: IndentOption @Binding var reformatAtColumn: Int @Binding var showReformattingGuide: Bool + @Binding var showFoldingRibbon: Bool @Binding var invisibles: InvisibleCharactersConfiguration @Binding var warningCharacters: Set @@ -47,6 +48,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.resolved b/Package.resolved index 511f99034..954e31f14 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "df485cb63e163c9bdc68ec0617c113d301368da6", - "version" : "0.11.3" + "revision" : "8f02a6b206091ee4aaee9006e2ef1ddc68e754c8", + "version" : "0.11.4" } }, { diff --git a/Package.swift b/Package.swift index 3d8026922..6b75d660c 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.3" + from: "0.11.4" ), // tree-sitter languages .package( 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 93% rename from Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 908d953c0..e33ac4213 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -9,6 +9,23 @@ 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 viewDidAppear() { + super.viewDidAppear() + textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) } + } + + override public func viewDidDisappear() { + super.viewDidDisappear() + textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) } + } + override public func loadView() { super.loadView() @@ -17,7 +34,7 @@ extension TextViewController { gutterView = GutterView( configuration: configuration, - textView: textView, + controller: self, delegate: self ) gutterView.updateWidthIfNeeded() @@ -129,6 +146,7 @@ extension TextViewController { self.gutterView.frame.size.height = self.textView.frame.height + 10 self.gutterView.frame.origin.y = self.textView.frame.origin.y - self.scrollView.contentInsets.top self.gutterView.needsDisplay = true + self.gutterView.foldingRibbon.needsDisplay = true self.reformattingGuideView?.updatePosition(in: self) self.scrollView.needsLayout = true } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 3f106b010..e59e5c10e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -13,7 +13,7 @@ extension TextViewController { // swiftlint:disable:next force_cast let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraph.tabStops.removeAll() - paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth + paragraph.defaultTabInterval = CGFloat(tabWidth) * font.charWidth return paragraph } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index e7a67f13c..b98ad44f4 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/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index f33afa002..769b74399 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -48,6 +48,9 @@ public class TextViewController: NSViewController { var localEvenMonitor: Any? var isPostingCursorNotification: Bool = false + /// A default `NSParagraphStyle` with a set `lineHeight` + lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + // MARK: - Public Variables /// Passthrough value for the `textView`s string @@ -170,23 +173,23 @@ public class TextViewController: NSViewController { /// This will be `nil` if another highlighter provider is passed to the source editor. internal(set) public var treeSitterClient: TreeSitterClient? - package var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } + var foldProvider: LineFoldProvider /// Filters used when applying edits.. - internal var textFilters: [TextFormation.Filter] = [] + var textFilters: [TextFormation.Filter] = [] - internal var cancellables = Set() + var cancellables = Set() /// The trailing inset for the editor. Grows when line wrapping is disabled or when the minimap is shown. - package var textViewTrailingInset: CGFloat { + var textViewTrailingInset: CGFloat { // See https://github.com/CodeEditApp/CodeEditTextView/issues/66 // wrapLines ? 1 : 48 (minimapView?.isHidden ?? false) ? 0 : (minimapView?.frame.width ?? 0.0) } - package var textViewInsets: HorizontalEdgeInsets { + var textViewInsets: HorizontalEdgeInsets { HorizontalEdgeInsets( - left: showGutter ? gutterView.gutterWidth : 0.0, + left: showGutter ? gutterView.frame.width : 0.0, right: textViewTrailingInset ) } @@ -199,6 +202,7 @@ public class TextViewController: NSViewController { configuration: SourceEditorConfiguration, cursorPositions: [CursorPosition], highlightProviders: [HighlightProviding] = [TreeSitterClient()], + foldProvider: LineFoldProvider? = nil, undoManager: CEUndoManager? = nil, coordinators: [TextViewCoordinator] = [] ) { @@ -206,6 +210,7 @@ public class TextViewController: NSViewController { self.configuration = configuration self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders + self.foldProvider = foldProvider ?? LineIndentationFoldProvider() self._undoManager = undoManager self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator(configuration: configuration) @@ -249,28 +254,6 @@ public class TextViewController: NSViewController { 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() - } - - override public func viewDidAppear() { - super.viewDidAppear() - textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) } - } - - override public func viewDidDisappear() { - super.viewDidDisappear() - textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) } - } - deinit { if let highlighter { textView.removeStorageDelegate(highlighter) diff --git a/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift new file mode 100644 index 000000000..bce82896c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift @@ -0,0 +1,29 @@ +// +// BracketPairs.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/5/25. +// + +enum BracketPairs { + static let allValues: [(String, String)] = [ + ("{", "}"), + ("[", "]"), + ("(", ")"), + ("\"", "\""), + ("'", "'") + ] + + static let emphasisValues: [(String, String)] = [ + ("{", "}"), + ("[", "]"), + ("(", ")") + ] + + /// Checks if the given string is a matchable emphasis string. + /// - Parameter potentialMatch: The string to check for matches. + /// - Returns: True if a match was found with either start or end bracket pairs. + static func matches(_ potentialMatch: String) -> Bool { + allValues.contains(where: { $0.0 == potentialMatch || $0.1 == potentialMatch }) + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift index 0b14ea65a..b579e8d52 100644 --- a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift +++ b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift @@ -27,13 +27,11 @@ extension DispatchQueue { /// executed if not already on the main thread. /// - Parameter item: The work item to execute. /// - Returns: The value of the work item. - static func syncMainIfNot(_ item: @escaping () -> T) -> T { + static func waitMainIfNot(_ item: () -> T) -> T { if Thread.isMainThread { return item() } else { - return DispatchQueue.main.sync { - return item() - } + return DispatchQueue.main.asyncAndWait(execute: item) } } } diff --git a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift new file mode 100644 index 000000000..25208fdcd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift @@ -0,0 +1,112 @@ +// +// 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) + public static let all: Corners = Corners(rawValue: 0b1111) + } + + // swiftlint:disable:next function_body_length + convenience init(rect: CGRect, roundedCorners corners: Corners, cornerRadius: CGFloat) { + self.init() + + let maxX = rect.maxX + let minX = rect.minX + let maxY = rect.maxY + let minY = rect.minY + let radius = min(cornerRadius, min(rect.width, rect.height) / 2) + + // Start at bottom-left corner + move(to: CGPoint(x: minX + (corners.contains(.bottomLeft) ? radius : 0), y: minY)) + + // Bottom edge + if corners.contains(.bottomRight) { + line(to: CGPoint(x: maxX - radius, y: minY)) + appendArc( + withCenter: CGPoint(x: maxX - radius, y: minY + radius), + radius: radius, + startAngle: 270, + endAngle: 0, + clockwise: false + ) + } else { + line(to: CGPoint(x: maxX, y: minY)) + } + + // Right edge + if corners.contains(.topRight) { + line(to: CGPoint(x: maxX, y: maxY - radius)) + appendArc( + withCenter: CGPoint(x: maxX - radius, y: maxY - radius), + radius: radius, + startAngle: 0, + endAngle: 90, + clockwise: false + ) + } else { + line(to: CGPoint(x: maxX, y: maxY)) + } + + // Top edge + if corners.contains(.topLeft) { + line(to: CGPoint(x: minX + radius, y: maxY)) + appendArc( + withCenter: CGPoint(x: minX + radius, y: maxY - radius), + radius: radius, + startAngle: 90, + endAngle: 180, + clockwise: false + ) + } else { + line(to: CGPoint(x: minX, y: maxY)) + } + + // Left edge + if corners.contains(.bottomLeft) { + line(to: CGPoint(x: minX, y: minY + radius)) + appendArc( + withCenter: CGPoint(x: minX + radius, y: minY + radius), + radius: radius, + startAngle: 180, + endAngle: 270, + clockwise: false + ) + } else { + line(to: CGPoint(x: minX, y: minY)) + } + + close() + } + + convenience init(roundingRect: CGRect, capTop: Bool, capBottom: Bool, cornerRadius radius: CGFloat) { + switch (capTop, capBottom) { + case (true, true): + self.init(rect: roundingRect) + case (false, true): + self.init(rect: roundingRect, roundedCorners: [.bottomLeft, .bottomRight], cornerRadius: radius) + case (true, false): + self.init(rect: roundingRect, roundedCorners: [.topLeft, .topRight], cornerRadius: radius) + case (false, false): + self.init(roundedRect: roundingRect, xRadius: radius, yRadius: radius) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/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/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/NSFont+CharWidth.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+CharWidth.swift new file mode 100644 index 000000000..606e1b681 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+CharWidth.swift @@ -0,0 +1,14 @@ +// +// NSFont+CharWidth.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/25/25. +// + +import AppKit + +extension NSFont { + var charWidth: CGFloat { + (" " as NSString).size(withAttributes: [.font: self]).width + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+LineHeight.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+LineHeight.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+RulerFont.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+RulerFont.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift new file mode 100644 index 000000000..34be2a8e7 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift @@ -0,0 +1,19 @@ +// +// NSRect+Transform.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/4/25. +// + +import AppKit + +extension NSRect { + func transform(x xVal: CGFloat = 0, y yVal: CGFloat = 0, width: CGFloat = 0, height: CGFloat = 0) -> NSRect { + NSRect( + x: self.origin.x + xVal, + y: self.origin.y + yVal, + width: self.width + width, + height: self.height + height + ) + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift b/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift new file mode 100644 index 000000000..870e11545 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift @@ -0,0 +1,19 @@ +// +// NSString+TextStory.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/3/25. +// + +import AppKit +import TextStory + +extension NSString: @retroactive TextStoring { + public func substring(from range: NSRange) -> String? { + self.substring(with: range) + } + + public func applyMutation(_ mutation: TextMutation) { + self.replacingCharacters(in: mutation.range, with: mutation.string) + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+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/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/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index e079783d4..e48d53ea1 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -30,6 +30,7 @@ class FindPanelViewModel: ObservableObject { self.target?.findPanelModeDidChange(to: mode) } } + @Published var findMethod: FindMethod = .contains { didSet { if !findText.isEmpty { diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 297d3e9c3..8be57ec62 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. @@ -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 @@ -69,12 +73,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,20 +100,49 @@ public class GutterView: NSView { fontLineHeight = (ascent + descent + leading) } + /// The view that draws the fold decoration in the gutter. + var foldingRibbon: LineFoldRibbonView + + /// Syntax helper for determining the required space for the folding ribbon. + private var foldingRibbonWidth: CGFloat { + if foldingRibbon.isHidden { + 0.0 + } else { + LineFoldRibbonView.width + foldingRibbonPadding + } + } + + /// 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 + foldingRibbonPadding, + y: 0.0, + width: foldingRibbonWidth, + height: newValue.height + ) + } + } + public convenience init( configuration: borrowing SourceEditorConfiguration, - textView: TextView, + controller: TextViewController, delegate: GutterViewDelegate? = nil ) { self.init( font: configuration.appearance.font, textColor: configuration.appearance.theme.text.color, selectedTextColor: configuration.appearance.theme.selection, - textView: textView, + controller: controller, delegate: delegate ) } @@ -113,15 +151,17 @@ 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 = LineFoldRibbonView(controller: controller) + super.init(frame: .zero) clipsToBounds = true wantsLayer = true @@ -129,6 +169,8 @@ public class GutterView: NSView { translatesAutoresizingMaskIntoConstraints = false layer?.masksToBounds = true + addSubview(foldingRibbon) + NotificationCenter.default.addObserver( forName: TextSelectionManager.selectionChangedNotification, object: nil, @@ -138,22 +180,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) @@ -163,27 +200,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 - foldingRibbonWidth, 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, @@ -197,7 +243,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), @@ -219,7 +265,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] @@ -233,9 +283,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 { @@ -252,7 +303,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) @@ -263,18 +314,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/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/LineFoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift new file mode 100644 index 000000000..6ffbd5adf --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift @@ -0,0 +1,51 @@ +// +// LineFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView + +/// Represents a fold's start or end. +public 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 + } + } +} + +/// ``LineFoldProvider`` is an interface used by the editor to find fold regions in a document. +/// +/// The only required method, ``LineFoldProvider/foldLevelAtLine(lineNumber:lineRange:previousDepth:controller:)``, +/// will be called very often. Return as fast as possible from this method, keeping in mind it is taking time on the +/// main thread. +/// +/// Ordering between calls is not guaranteed, the provider may restart at any time. The implementation should provide +/// fold info for only the given lines. +@MainActor +public protocol LineFoldProvider: AnyObject { + func foldLevelAtLine( + lineNumber: Int, + lineRange: NSRange, + previousDepth: Int, + controller: TextViewController + ) -> [LineFoldProviderLineInfo] +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineIndentationFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineIndentationFoldProvider.swift new file mode 100644 index 000000000..5c04b0d7d --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineIndentationFoldProvider.swift @@ -0,0 +1,65 @@ +// +// LineIndentationFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +/// A basic fold provider that uses line indentation to determine fold regions. +final class LineIndentationFoldProvider: LineFoldProvider { + func indentLevelAtLine(substring: NSString) -> Int? { + for idx in 0.. [LineFoldProviderLineInfo] { + let text = controller.textView.textStorage.string as NSString + guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesAndNewlines)?.length, + leadingIndent != lineRange.length else { + return [] + } + var foldIndicators: [LineFoldProviderLineInfo] = [] + + let leadingDepth = leadingIndent / controller.indentOption.charCount + if leadingDepth < previousDepth { + // End the fold at the start of whitespace + foldIndicators.append( + .endFold( + rangeEnd: lineRange.location + leadingIndent, + newDepth: leadingDepth + ) + ) + } + + // Check if the next line has more indent + let maxRange = NSRange(start: lineRange.max, end: text.length) + guard let nextIndent = text.leadingRange(in: maxRange, within: .whitespacesWithoutNewlines)?.length, + nextIndent > 0 else { + return foldIndicators + } + + if nextIndent > leadingIndent, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) { + foldIndicators.append( + .startFold( + rangeStart: trailingWhitespace.location, + newDepth: nextIndent / controller.indentOption.charCount + ) + ) + } + + return foldIndicators + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift new file mode 100644 index 000000000..aa6c9ace8 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift @@ -0,0 +1,20 @@ +// +// FoldRange.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/26/25. +// + +/// Represents a single fold region with stable identifier and collapse state +struct FoldRange: Sendable, Equatable { + typealias FoldIdentifier = UInt32 + + let id: FoldIdentifier + let depth: Int + let range: Range + var isCollapsed: Bool + + func isHoveringEqual(_ other: FoldRange) -> Bool { + depth == other.depth && range.contains(other.range) + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift new file mode 100644 index 000000000..939d27dc3 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -0,0 +1,186 @@ +// +// LineFoldCalculator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView + +/// `LineFoldCalculator` receives text edits and rebuilds fold regions asynchronously. +/// +/// This is an actor, all methods and modifications happen in isolation in it's async region. All text requests are +/// marked `@MainActor` for safety. +actor LineFoldCalculator { + weak var foldProvider: LineFoldProvider? + weak var controller: TextViewController? + + var valueStream: AsyncStream + + private var valueStreamContinuation: AsyncStream.Continuation + private var textChangedTask: Task? + + /// Create a new calculator object that listens to a given stream for text changes. + /// - Parameters: + /// - foldProvider: The object to use to calculate fold regions. + /// - controller: The text controller to use for text and attachment fetching. + /// - textChangedStream: A stream of text changes, received as the document is edited. + init( + foldProvider: LineFoldProvider, + controller: TextViewController, + textChangedStream: AsyncStream + ) { + // This could be grabbed from the controller, but Swift 6 doesn't like that (concurrency safety) + self.foldProvider = foldProvider + self.controller = controller + (valueStream, valueStreamContinuation) = AsyncStream.makeStream() + Task { await listenToTextChanges(textChangedStream: textChangedStream) } + } + + deinit { + textChangedTask?.cancel() + } + + /// Sets up an attached task to listen to values on a stream of text changes. + /// - Parameter textChangedStream: A stream of text changes. + private func listenToTextChanges(textChangedStream: AsyncStream) { + textChangedTask = Task { + for await _ in textChangedStream { + await 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() 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 + let lineIterator = await ChunkedLineIterator( + controller: controller, + foldProvider: foldProvider, + textIterator: await controller.textView.layoutManager.lineStorage.makeIterator() + ) + + for await lineChunk in lineIterator { + for lineInfo in lineChunk { + // Start a new fold, going deeper to a new depth. + if lineInfo.depth > currentDepth { + let newFold = LineFoldStorage.RawFold( + depth: lineInfo.depth, + range: lineInfo.rangeIndice.. received depth + for openFold in openFolds.values.filter({ $0.depth > lineInfo.depth }) { + openFolds.removeValue(forKey: openFold.depth) + foldCache.append( + LineFoldStorage.RawFold( + depth: openFold.depth, + range: openFold.range.lowerBound.. LineFoldStorage.DepthStartPair? in + guard let attachment = attachmentBox.attachment as? LineFoldPlaceholder else { + return nil + } + return LineFoldStorage.DepthStartPair(depth: attachment.fold.depth, start: attachmentBox.range.location) + } + + let storage = LineFoldStorage( + documentLength: newFolds.max( + by: { $0.range.upperBound < $1.range.upperBound } + )?.range.upperBound ?? documentRange.length, + folds: newFolds.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }), + collapsedRanges: Set(attachments) + ) + valueStreamContinuation.yield(storage) + } + + /// Asynchronously gets more line information from the fold provider. + /// Runs on the main thread so all text-related calculations are safe with the main text storage. + /// + /// Has to be an `AsyncSequence` so it can be main actor isolated. + @MainActor + struct ChunkedLineIterator: AsyncSequence, AsyncIteratorProtocol { + var controller: TextViewController + var foldProvider: LineFoldProvider + private var previousDepth: Int = 0 + var textIterator: TextLineStorage.TextLineStorageIterator + + init( + controller: TextViewController, + foldProvider: LineFoldProvider, + textIterator: TextLineStorage.TextLineStorageIterator + ) { + self.controller = controller + self.foldProvider = foldProvider + self.textIterator = textIterator + } + + nonisolated func makeAsyncIterator() -> ChunkedLineIterator { + self + } + + mutating func next() -> [LineFoldProviderLineInfo]? { + var results: [LineFoldProviderLineInfo] = [] + var count = 0 + var previousDepth: Int = previousDepth + while count < 50, let linePosition = textIterator.next() { + let foldInfo = foldProvider.foldLevelAtLine( + lineNumber: linePosition.index, + lineRange: linePosition.range, + previousDepth: previousDepth, + controller: controller + ) + results.append(contentsOf: foldInfo) + count += 1 + previousDepth = foldInfo.max(by: { $0.depth < $1.depth })?.depth ?? previousDepth + } + if results.isEmpty && count == 0 { + return nil + } + return results + } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift new file mode 100644 index 000000000..03de8ac7d --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift @@ -0,0 +1,155 @@ +// +// LineFoldModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView +import Combine + +/// This object acts as the conductor between the line folding components. +/// +/// This receives text changed events, and notifies the line fold calculator. +/// It then receives fold calculation updates, and notifies the drawing view. +/// It manages a cache of fold ranges for drawing. +/// +/// For fold storage and querying, see ``LineFoldStorage``. For fold calculation see ``LineFoldCalculator`` +/// and ``LineFoldProvider``. For drawing see ``LineFoldRibbonView``. +class LineFoldModel: 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) + private var calculator: LineFoldCalculator + + private var textChangedStream: AsyncStream + private var textChangedStreamContinuation: AsyncStream.Continuation + private var cacheListenTask: Task? + + weak var controller: TextViewController? + weak var foldView: NSView? + + init(controller: TextViewController, foldView: NSView) { + self.controller = controller + self.foldView = foldView + (textChangedStream, textChangedStreamContinuation) = AsyncStream.makeStream() + self.calculator = LineFoldCalculator( + foldProvider: controller.foldProvider, + controller: controller, + textChangedStream: textChangedStream + ) + super.init() + controller.textView.addStorageDelegate(self) + + cacheListenTask = Task { @MainActor [weak foldView] in + for await newFolds in await calculator.valueStream { + foldCache = newFolds + foldView?.needsDisplay = true + } + } + textChangedStreamContinuation.yield(Void()) + } + + func getFolds(in range: Range) -> [FoldRange] { + foldCache.folds(in: range) + } + + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { + return + } + foldCache.storageUpdated(editedRange: editedRange, changeInLength: delta) + textChangedStreamContinuation.yield() + } + + /// Finds the deepest cached depth of the fold for a line number. + /// - Parameter lineNumber: The line number to query, zero-indexed. + /// - Returns: The deepest cached depth of the fold if it was found. + func getCachedDepthAt(lineNumber: Int) -> Int? { + return getCachedFoldAt(lineNumber: lineNumber)?.depth + } + + /// Finds the deepest cached fold and depth of the fold for a line number. + /// - Parameter lineNumber: The line number to query, zero-indexed. + /// - Returns: The deepest cached fold and depth of the fold if it was found. + func getCachedFoldAt(lineNumber: Int) -> FoldRange? { + guard let lineRange = controller?.textView.layoutManager.textLineForIndex(lineNumber)?.range else { return nil } + guard let deepestFold = foldCache.folds(in: lineRange.intRange).max(by: { + if $0.isCollapsed != $1.isCollapsed { + $1.isCollapsed // Collapsed folds take precedence. + } else if $0.isCollapsed { + $0.depth > $1.depth + } else { + $0.depth < $1.depth + } + }) else { + return nil + } + return deepestFold + } + + 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) + } +} + +// MARK: - LineFoldPlaceholderDelegate + +extension LineFoldModel: LineFoldPlaceholderDelegate { + func placeholderBackgroundColor() -> NSColor { + controller?.configuration.appearance.theme.invisibles.color ?? .lightGray + } + + func placeholderTextColor() -> NSColor { + controller?.configuration.appearance.theme.text.color.withAlphaComponent(0.35) ?? .tertiaryLabelColor + } + + func placeholderSelectedColor() -> NSColor { + .controlAccentColor + } + + func placeholderSelectedTextColor() -> NSColor { + controller?.theme.background ?? .controlBackgroundColor + } + + func placeholderDiscarded(fold: FoldRange) { + foldCache.toggleCollapse(forFold: fold) + foldView?.needsDisplay = true + textChangedStreamContinuation.yield() + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift new file mode 100644 index 000000000..b734be850 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -0,0 +1,109 @@ +// +// LineFoldStorage.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// +import _RopeModule +import Foundation + +/// Sendable data model for code folding using RangeStore +struct LineFoldStorage: Sendable { + /// A temporary fold representation without stable ID + struct RawFold: Sendable { + let depth: Int + let range: Range + } + + struct DepthStartPair: Hashable { + let depth: Int + let start: Int + } + + /// Element stored in RangeStore: holds reference to a fold region + struct FoldStoreElement: RangeStoreElement, Sendable { + let id: FoldRange.FoldIdentifier + let depth: Int + + var isEmpty: Bool { false } + } + + private var idCounter = FoldRange.FoldIdentifier.zero + private var store: RangeStore + private var foldRanges: [FoldRange.FoldIdentifier: FoldRange] = [:] + + /// Initialize with the full document length + init(documentLength: Int, folds: [RawFold] = [], collapsedRanges: Set = []) { + self.store = RangeStore(documentLength: documentLength) + self.updateFolds(from: folds, collapsedRanges: collapsedRanges) + } + + private mutating func nextFoldId() -> FoldRange.FoldIdentifier { + idCounter += 1 + return idCounter + } + + /// Replace all fold data from raw folds, preserving collapse state via callback + /// - Parameter rawFolds: newly computed folds (depth + range) + /// - Parameter collapsedRanges: Current collapsed ranges/depths + mutating func updateFolds(from rawFolds: [RawFold], collapsedRanges: Set) { + // Build reuse map by start+depth, carry over collapse state + var reuseMap: [DepthStartPair: FoldRange] = [:] + for region in foldRanges.values { + reuseMap[DepthStartPair(depth: region.depth, start: region.range.lowerBound)] = region + } + + // Build new regions + foldRanges.removeAll(keepingCapacity: true) + store = RangeStore(documentLength: store.length) + + for raw in rawFolds { + let key = DepthStartPair(depth: raw.depth, start: raw.range.lowerBound) + // reuse id and collapse state if available + let prior = reuseMap[key] + let id = prior?.id ?? nextFoldId() + let wasCollapsed = prior?.isCollapsed ?? false + // override collapse if provider says so + let isCollapsed = collapsedRanges.contains(key) || wasCollapsed + let fold = FoldRange(id: id, depth: raw.depth, range: raw.range, isCollapsed: isCollapsed) + + foldRanges[id] = fold + let elem = FoldStoreElement(id: id, depth: raw.depth) + store.set(value: elem, for: raw.range) + } + } + + /// Keep folding offsets in sync after text edits + mutating func storageUpdated(editedRange: NSRange, changeInLength delta: Int) { + store.storageUpdated(editedRange: editedRange, changeInLength: delta) + } + + mutating func toggleCollapse(forFold fold: FoldRange) { + guard var existingRange = foldRanges[fold.id] else { return } + existingRange.isCollapsed.toggle() + foldRanges[fold.id] = existingRange + } + + /// Query a document subrange and return all folds as an ordered list by start position + func folds(in queryRange: Range) -> [FoldRange] { + let runs = store.runs(in: queryRange.clamped(to: 0.. = [] + var result: [FoldRange] = [] + + for run in runs { + if let elem = run.value, !alreadyReturnedIDs.contains(elem.id), let range = foldRanges[elem.id] { + result.append( + FoldRange( + id: elem.id, + depth: elem.depth, + range: range.range, + isCollapsed: range.isCollapsed + ) + ) + alreadyReturnedIDs.insert(elem.id) + } + } + + return result.sorted { $0.range.lowerBound < $1.range.lowerBound } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift new file mode 100644 index 000000000..67758cf45 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -0,0 +1,87 @@ +// +// LineFoldPlaceholder.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView + +protocol LineFoldPlaceholderDelegate: AnyObject { + func placeholderBackgroundColor() -> NSColor + func placeholderTextColor() -> NSColor + + func placeholderSelectedColor() -> NSColor + func placeholderSelectedTextColor() -> NSColor + + func placeholderDiscarded(fold: FoldRange) +} + +/// Used to display a folded region in a text document. +/// +/// To stay up-to-date with the user's theme, it uses the ``LineFoldPlaceholderDelegate`` to query for current colors +/// to use for drawing. +class LineFoldPlaceholder: TextAttachment { + let fold: FoldRange + let charWidth: CGFloat + var isSelected: Bool = false + weak var delegate: LineFoldPlaceholderDelegate? + + init(delegate: LineFoldPlaceholderDelegate?, fold: FoldRange, charWidth: CGFloat) { + self.fold = fold + self.delegate = delegate + self.charWidth = charWidth + } + + var width: CGFloat { + charWidth * 5 + } + + func draw(in context: CGContext, rect: NSRect) { + context.saveGState() + + guard let delegate else { return } + + let size = charWidth / 2.5 + let centerY = rect.midY - (size / 2.0) + + if isSelected { + context.setFillColor(delegate.placeholderSelectedColor().cgColor) + } else { + context.setFillColor(delegate.placeholderBackgroundColor().cgColor) + } + + context.addPath( + NSBezierPath( + rect: rect.transform(x: charWidth, y: 2.0, width: -charWidth * 2, height: -4.0), + roundedCorners: .all, + cornerRadius: rect.height / 2 + ).cgPathFallback + ) + context.fillPath() + + if isSelected { + context.setFillColor(delegate.placeholderSelectedTextColor().cgColor) + } else { + context.setFillColor(delegate.placeholderTextColor().cgColor) + } + context.addEllipse( + in: CGRect(x: rect.minX + (charWidth * 2) - size, y: centerY, width: size, height: size) + ) + context.addEllipse( + in: CGRect(x: rect.midX - (size / 2), y: centerY, width: size, height: size) + ) + context.addEllipse( + in: CGRect(x: rect.maxX - (charWidth * 2), y: centerY, width: size, height: size) + ) + context.fillPath() + + context.restoreGState() + } + + func attachmentAction() -> TextAttachmentAction { + delegate?.placeholderDiscarded(fold: fold) + return .discard + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift new file mode 100644 index 000000000..581379369 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift @@ -0,0 +1,335 @@ +// +// LineFoldRibbonView+Draw.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +extension LineFoldRibbonView { + struct DrawingFoldInfo { + let fold: FoldRange + let startLine: TextLineStorage.TextLinePosition + let endLine: TextLineStorage.TextLinePosition + } + + // MARK: - Draw + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext, + 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) + + // Only draw folds in the requested dirty rect + let folds = getDrawingFolds( + forTextRange: rangeStart.range.location.. 1. In this case, we still need to draw those + /// layers of color to create the illusion that those folds are continuous under the nested folds. To achieve this, + /// we create 'fake' folds that span more than the queried text range. When returned for drawing, the drawing + /// methods will draw those extra folds normally. + /// + /// - Parameters: + /// - textRange: The range of characters in text to create drawing fold info for. + /// - layoutManager: A layout manager to query for line layout information. + /// - Returns: A list of folds to draw for the given text range. + private func getDrawingFolds( + forTextRange textRange: Range, + layoutManager: TextLayoutManager + ) -> [DrawingFoldInfo] { + var folds = model?.getFolds(in: textRange) ?? [] + + // Add in some fake depths, we can draw these underneath the rest of the folds to make it look like it's + // continuous + if let minimumDepth = folds.min(by: { $0.depth < $1.depth })?.depth { + for depth in (1.. + private let endIndices: Set + private let collapsedStartIndices: Set + private let collapsedEndIndices: Set + + init(_ folds: [DrawingFoldInfo]) { + 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) || collapsedEndIndices.contains(fold.startLine.index) + } + + func foldNeedsBottomCap(_ fold: DrawingFoldInfo) -> Bool { + 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( + using fold: DrawingFoldInfo, + rect: NSRect + ) -> NSRect { + let capTop = foldNeedsTopCap(fold) + let capBottom = foldNeedsBottomCap(fold) + let yDelta: CGFloat = if capTop && !collapsedEndIndices.contains(fold.startLine.index) { + fold.startLine.height / 2.0 + } else { + 0.0 + } + + var heightDelta: CGFloat = 0.0 + if capTop && !collapsedEndIndices.contains(fold.startLine.index) { + heightDelta -= fold.startLine.height / 2.0 + } + if capBottom && !collapsedStartIndices.contains(fold.endLine.index) { + heightDelta -= fold.endLine.height / 2.0 + } + + return NSRect( + x: rect.origin.x, + y: rect.origin.y + yDelta, + width: rect.size.width, + height: rect.size.height + heightDelta + ) + } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView.swift new file mode 100644 index 000000000..e2b9d54f0 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView.swift @@ -0,0 +1,217 @@ +// +// LineFoldRibbonView.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 manually. This was chosen over managing views on a per-fold basis, which would come +/// with needing to manage view reuse and positioning. Drawing allows this view to draw only what macOS requests, and +/// ends up being extremely efficient. This does mean that animations have to be done manually with a timer. +/// Re: the `hoveredFold` property. +class LineFoldRibbonView: NSView { + struct HoverAnimationDetails: Equatable { + var fold: FoldRange? + var foldMask: CGPath? + var timer: Timer? + var progress: CGFloat = 0.0 + + static let empty = HoverAnimationDetails() + + public static func == (_ lhs: HoverAnimationDetails, _ rhs: HoverAnimationDetails) -> Bool { + lhs.fold == rhs.fold && lhs.foldMask == rhs.foldMask && lhs.progress == rhs.progress + } + } + + static let width: CGFloat = 7.0 + + var model: LineFoldModel? + + @Invalidating(.display) + var hoveringFold: HoverAnimationDetails = .empty + + @Invalidating(.display) + var backgroundColor: NSColor = NSColor.controlBackgroundColor + + @Invalidating(.display) + 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( + light: NSColor(deviceWhite: 1.0, alpha: 0.4), + dark: NSColor(deviceWhite: 0.0, alpha: 0.4) + ).cgColor + + @Invalidating(.display) + 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( + light: NSColor(deviceWhite: 0.8, alpha: 1.0), + dark: NSColor(deviceWhite: 0.4, alpha: 1.0) + ).cgColor + + @Invalidating(.display) + var foldedIndicatorColor = NSColor( + light: NSColor(deviceWhite: 0.0, alpha: 0.3), + dark: NSColor(deviceWhite: 1.0, alpha: 0.6) + ).cgColor + + @Invalidating(.display) + var foldedIndicatorChevronColor = NSColor( + light: NSColor(deviceWhite: 1.0, alpha: 1.0), + dark: NSColor(deviceWhite: 0.0, alpha: 1.0) + ).cgColor + + override public var isFlipped: Bool { + true + } + + init(controller: TextViewController) { + super.init(frame: .zero) + layerContentsRedrawPolicy = .onSetNeedsDisplay + clipsToBounds = false + self.model = LineFoldModel( + controller: controller, + foldView: self + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func resetCursorRects() { + // Don't use an iBeam in this view + addCursorRect(bounds, cursor: .arrow) + } + + // MARK: - Hover + + override func updateTrackingAreas() { + trackingAreas.forEach(removeTrackingArea) + let area = NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .activeInKeyWindow, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + } + + override func scrollWheel(with event: NSEvent) { + super.scrollWheel(with: event) + self.mouseMoved(with: event) + } + + // MARK: - Mouse Events + + override func mouseDown(with event: NSEvent) { + let clickPoint = convert(event.locationInWindow, from: nil) + guard let layoutManager = model?.controller?.textView.layoutManager, + event.type == .leftMouseDown, + let lineNumber = layoutManager.textLineForPosition(clickPoint.y)?.index, + let fold = model?.getCachedFoldAt(lineNumber: lineNumber), + let firstLineInFold = layoutManager.textLineForOffset(fold.range.lowerBound) else { + super.mouseDown(with: event) + return + } + + if let attachment = findAttachmentFor(fold: fold, firstLineRange: firstLineInFold.range) { + layoutManager.attachments.remove(atOffset: attachment.range.location) + } else { + let charWidth = model?.controller?.font.charWidth ?? 1.0 + let placeholder = LineFoldPlaceholder(delegate: model, fold: fold, charWidth: charWidth) + layoutManager.attachments.add(placeholder, for: NSRange(fold.range)) + } + + model?.foldCache.toggleCollapse(forFold: fold) + model?.controller?.textView.needsLayout = true + model?.controller?.gutterView.needsDisplay = true + mouseMoved(with: event) + } + + private func findAttachmentFor(fold: FoldRange, firstLineRange: NSRange) -> AnyTextAttachment? { + model?.controller?.textView?.layoutManager.attachments + .getAttachmentsStartingIn(NSRange(fold.range)) + .filter({ + $0.attachment is LineFoldPlaceholder && firstLineRange.contains($0.range.location) + }).first + } + + override func mouseMoved(with event: NSEvent) { + defer { + super.mouseMoved(with: event) + } + + let pointInView = convert(event.locationInWindow, from: nil) + guard let lineNumber = model?.controller?.textView.layoutManager.textLineForPosition(pointInView.y)?.index, + let fold = model?.getCachedFoldAt(lineNumber: lineNumber), + !fold.isCollapsed else { + clearHoveredFold() + return + } + + guard fold.range != hoveringFold.fold?.range else { + return + } + + 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.fold == nil { + let duration: TimeInterval = 0.2 + let startTime = CACurrentMediaTime() + + 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 + hoveringFold = HoverAnimationDetails(fold: fold, progress: 1.0) + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift index 02ac3c2bc..e148a0e76 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -50,7 +50,19 @@ final class MinimapLineFragmentView: LineFragmentView { // Create the drawing runs using attribute information var position = newFragment.documentRange.location - while position < newFragment.documentRange.max { + for contentRun in newFragment.contents { + switch contentRun.data { + case .text: + addDrawingRunsUntil(max: position + contentRun.length, position: &position, textStorage: textStorage) + case .attachment(let attachment): + position += attachment.range.length + appendDrawingRun(color: .clear, range: NSRange(location: position, length: contentRun.length)) + } + } + } + + private func addDrawingRunsUntil(max: Int, position: inout Int, textStorage: NSTextStorage) { + while position < max { var longestRange: NSRange = .notFound defer { position = longestRange.max } @@ -58,7 +70,7 @@ final class MinimapLineFragmentView: LineFragmentView { .foregroundColor, at: position, longestEffectiveRange: &longestRange, - in: NSRange(start: position, end: newFragment.documentRange.max) + in: NSRange(start: position, end: max) ) as? NSColor else { continue } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index 0a5a050f3..e6366c768 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -34,7 +34,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { range: range, stringRef: stringRef, markedRanges: markedRanges, - attachments: [] + attachments: attachments ) // Make all fragments 2px tall diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextAttachmentManagerDelegate.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextAttachmentManagerDelegate.swift new file mode 100644 index 000000000..1b0794230 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextAttachmentManagerDelegate.swift @@ -0,0 +1,30 @@ +// +// MinimapView+TextAttachmentManagerDelegate.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/25/25. +// + +import AppKit +import CodeEditTextView + +extension MinimapView: TextAttachmentManagerDelegate { + class MinimapAttachment: TextAttachment { + var isSelected: Bool = false + var width: CGFloat + + init(_ other: TextAttachment, widthRatio: CGFloat) { + self.width = other.width * widthRatio + } + + func draw(in context: CGContext, rect: NSRect) { } + } + + public func textAttachmentDidAdd(_ attachment: TextAttachment, for range: NSRange) { + layoutManager?.attachments.add(MinimapAttachment(attachment, widthRatio: editorToMinimapWidthRatio), for: range) + } + + public func textAttachmentDidRemove(_ attachment: TextAttachment, for range: NSRange) { + layoutManager?.attachments.remove(atOffset: range.location) + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index d515d94d1..afd97dee7 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -63,6 +63,10 @@ public class MinimapView: FlippedNSView { minimapHeight / editorHeight } + var editorToMinimapWidthRatio: CGFloat { + 3.0 / (textView?.font.charWidth ?? 3.0) + } + /// The height of the available container, less the scroll insets to reflect the visible height. var containerHeight: CGFloat { scrollView.visibleRect.height - scrollView.contentInsets.vertical @@ -144,6 +148,7 @@ public class MinimapView: FlippedNSView { delegate: self, renderDelegate: lineRenderer ) + textView.layoutManager.attachments.delegate = self self.layoutManager = layoutManager self.contentView.layoutManager = layoutManager (textView.textStorage.delegate as? MultiStorageDelegate)?.addDelegate(layoutManager) diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift index 363768d2e..0c3438e09 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift @@ -12,4 +12,11 @@ extension RangeStore { func findIndex(at offset: Int) -> (index: Index, remaining: Int) { _guts.find(at: offset, in: OffsetMetric(), preferEnd: false) } + + /// Finds the value stored at a given string offset. + /// - Parameter offset: The offset to query for. + /// - Returns: The element stored, if any. + func findValue(at offset: Int) -> Element? { + _guts[findIndex(at: offset).index].value + } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 5e22fc5f8..d41c0e478 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,25 @@ 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.clamped(to: 0..<_guts.count(in: OffsetMetric())), + withCount: newLength + ) + } + /// Handles keeping the internal storage in sync with the document. mutating func storageUpdated(replacedCharactersIn range: Range, withCount newLength: Int) { assert(range.lowerBound >= 0, "Negative lowerBound") diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index e4033781e..6c58b8dfd 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -77,7 +77,7 @@ class ReformattingGuideView: NSView { func updatePosition(in controller: TextViewController) { // Calculate the x position based on the font's character width and column number let xPosition = ( - CGFloat(column) * (controller.fontCharWidth / 2) // Divide by 2 to account for coordinate system + CGFloat(column) * (controller.font.charWidth / 2) // Divide by 2 to account for coordinate system + (controller.textViewInsets.left / 2) ) diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index 7385271a0..b77cc719f 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -16,6 +16,9 @@ extension SourceEditorConfiguration { /// Whether to show the reformatting guide. public var showReformattingGuide: Bool + /// Whether to show the folding ribbon. Only available if ``showGutter`` is `true`. + public var showFoldingRibbon: Bool + /// Configuration for drawing invisible characters. /// /// See ``InvisibleCharactersConfiguration`` for more details. @@ -29,12 +32,14 @@ extension SourceEditorConfiguration { showGutter: Bool = true, showMinimap: Bool = true, showReformattingGuide: Bool = false, + showFoldingRibbon: Bool = true, invisibleCharactersConfiguration: InvisibleCharactersConfiguration = .empty, warningCharacters: Set = [] ) { self.showGutter = showGutter self.showMinimap = showMinimap self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon self.invisibleCharactersConfiguration = invisibleCharactersConfiguration self.warningCharacters = warningCharacters } @@ -58,6 +63,10 @@ extension SourceEditorConfiguration { controller.reformattingGuideView.updatePosition(in: controller) } + if oldConfig?.showFoldingRibbon != showFoldingRibbon { + controller.gutterView.showFoldingRibbon = showFoldingRibbon + } + if oldConfig?.invisibleCharactersConfiguration != invisibleCharactersConfiguration { controller.invisibleCharactersCoordinator.configuration = invisibleCharactersConfiguration } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift b/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift index 4de83868e..b2815fbf6 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift @@ -22,6 +22,10 @@ final package class Atomic { } } + func withValue(_ handler: (T) -> F) -> F { + lock.withLock { handler(wrappedValue) } + } + func value() -> T { lock.withLock { wrappedValue } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift index ce67a0c93..89d934914 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift @@ -158,7 +158,7 @@ public class LanguageLayer: Hashable { } wasLongParse = true } - newTree = DispatchQueue.syncMainIfNot { parser.parse(tree: tree, readBlock: readBlock) } + newTree = DispatchQueue.waitMainIfNot { parser.parse(tree: tree, readBlock: readBlock) } } if wasLongParse { diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index c3dbaaece..c17feea24 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/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! @@ -208,24 +208,27 @@ final class TextViewControllerTests: XCTestCase { // Insert lots of spaces controller.configuration.behavior.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.configuration.appearance.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.configuration.appearance.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 ) @@ -241,7 +244,7 @@ final class TextViewControllerTests: XCTestCase { controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.configuration.appearance.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { @@ -280,7 +283,7 @@ final class TextViewControllerTests: XCTestCase { } func test_findClosingPair() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.textView.string = "{ Lorem Ipsum {} }" var idx: Int? @@ -295,28 +298,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)) @@ -336,7 +351,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 @@ -377,7 +392,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 @@ -443,6 +458,25 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) } + // MARK: Folding Ribbon + + func test_foldingRibbonToggle() { + controller.setText("Hello World") + controller.configuration.peripherals.showFoldingRibbon = false + XCTAssertFalse(controller.gutterView.showFoldingRibbon) + controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass + let noRibbonWidth = controller.gutterView.frame.width + + controller.configuration.peripherals.showFoldingRibbon = true + XCTAssertTrue(controller.gutterView.showFoldingRibbon) + XCTAssertFalse(controller.gutterView.foldingRibbon.isHidden) + controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass + XCTAssertEqual( + controller.gutterView.frame.width, + noRibbonWidth + 7.0 + controller.gutterView.foldingRibbonPadding + ) + } + // MARK: - Get Overlapping Lines func test_getOverlappingLines() { @@ -545,4 +579,5 @@ final class TextViewControllerTests: XCTestCase { } } } -// swiftlint:enable all + +// swiftlint:disable:this file_length diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift new file mode 100644 index 000000000..44467bde2 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift @@ -0,0 +1,53 @@ +// +// LineFoldStorageTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/29/25. +// + +import Testing +@testable import CodeEditSourceEditor + +struct LineFoldStorageTests { + // Helper to create a collapsed provider set + private func collapsedSet(_ items: (Int, Int)...) -> Set { + Set(items.map { (depth, start) in + LineFoldStorage.DepthStartPair(depth: depth, start: start) + }) + } + + @Test + func emptyStorage() { + let storage = LineFoldStorage(documentLength: 50) + let folds = storage.folds(in: 0..<50) + #expect(folds.isEmpty) + } + + @Test + func updateFoldsBasic() { + var storage = LineFoldStorage(documentLength: 20) + let raw: [LineFoldStorage.RawFold] = [ + LineFoldStorage.RawFold(depth: 1, range: 0..<5), + LineFoldStorage.RawFold(depth: 2, range: 5..<10) + ] + storage.updateFolds(from: raw, collapsedRanges: []) + + let folds = storage.folds(in: 0..<20) + #expect(folds.count == 2) + #expect(folds[0].depth == 1 && folds[0].range == 0..<5 && folds[0].isCollapsed == false) + #expect(folds[1].depth == 2 && folds[1].range == 5..<10 && folds[1].isCollapsed == false) + } + + @Test + func preserveCollapseState() { + var storage = LineFoldStorage(documentLength: 15) + let raw = [LineFoldStorage.RawFold(depth: 1, range: 0..<5)] + // First pass: no collapsed + storage.updateFolds(from: raw, collapsedRanges: []) + #expect(storage.folds(in: 0..<15).first?.isCollapsed == false) + + // Second pass: provider marks depth=1, start=0 as collapsed + storage.updateFolds(from: raw, collapsedRanges: collapsedSet((1, 0))) + #expect(storage.folds(in: 0..<15).first?.isCollapsed == true) + } +} diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift new file mode 100644 index 000000000..2787b4076 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -0,0 +1,70 @@ +// +// LineFoldingModelTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import Testing +import AppKit +import CodeEditTextView +@testable import CodeEditSourceEditor + +@MainActor +struct LineFoldingModelTests { + /// Makes a fold pattern that increases until halfway through the document then goes back to zero. + @MainActor + class HillPatternFoldProvider: LineFoldProvider { + func foldLevelAtLine( + lineNumber: Int, + 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() + controller.foldProvider = provider + let model = LineFoldModel(controller: controller, foldView: NSView()) + + 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) + } +}