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.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/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..2a5125789 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,10 +100,39 @@ 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 + 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 init( font: NSFont, textColor: NSColor, @@ -108,6 +146,8 @@ public class GutterView: NSView { self.textView = textView self.delegate = delegate + foldingRibbon = FoldingRibbonView(textView: textView, foldProvider: nil) + super.init(frame: .zero) clipsToBounds = true wantsLayer = true @@ -115,6 +155,8 @@ public class GutterView: NSView { translatesAutoresizingMaskIntoConstraints = false layer?.masksToBounds = true + addSubview(foldingRibbon) + NotificationCenter.default.addObserver( forName: TextSelectionManager.selectionChangedNotification, object: nil, @@ -124,22 +166,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 +186,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, @@ -183,7 +229,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 +251,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 +269,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 +289,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 +300,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/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/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..d7d8543bf --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -0,0 +1,210 @@ +// +// 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 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..b2e4dfbcf --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -0,0 +1,155 @@ +// +// 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 + } +} 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/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/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..126905be2 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.frame.width + + controller.showFoldingRibbon = true + XCTAssertTrue(controller.gutterView.showFoldingRibbon) + controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass + XCTAssertEqual(controller.gutterView.frame.width, noRibbonWidth + 7.0) + } } -// swiftlint:enable all + +// swiftlint:disable:this file_length