diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index fb95c81c1..96c6e2cd7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -154,6 +154,9 @@ public class TextViewController: NSViewController { /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. public var bracketPairEmphasis: BracketPairEmphasis? { configuration.appearance.bracketPairEmphasis } + /// The optional vertical separator between the gutter and editor content. + public var gutterSeparator: GutterSeparator? { configuration.appearance.gutterSeparator } + /// The column at which to show the reformatting guide public var reformatAtColumn: Int { configuration.behavior.reformatAtColumn } diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 8be57ec62..676501e5e 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -13,6 +13,27 @@ public protocol GutterViewDelegate: AnyObject { func gutterViewWidthDidUpdate() } +/// Configures the vertical separator between the gutter and editor content. +public struct GutterSeparator: Equatable { + /// The separator's stroke color. + public var color: NSColor + /// The separator's width in points. + public var lineWidth: CGFloat + /// Alternating painted and unpainted lengths. An empty array draws a solid line. + public var dashPattern: [CGFloat] + + /// Creates a gutter separator. + /// - Parameters: + /// - color: The separator's stroke color. + /// - lineWidth: The separator's width in points. + /// - dashPattern: Alternating painted and unpainted lengths. Leave empty for a solid line. + public init(color: NSColor = .separatorColor, lineWidth: CGFloat = 1, dashPattern: [CGFloat] = []) { + self.color = color + self.lineWidth = lineWidth + self.dashPattern = dashPattern + } +} + /// The gutter view displays line numbers that match the text view's line indexes. /// This view is used as a scroll view's ruler view. It sits on top of the text view so text scrolls underneath the /// gutter if line wrapping is disabled. @@ -73,6 +94,14 @@ public class GutterView: NSView { @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + var separator: GutterSeparator? { + didSet { + if oldValue != separator { + needsDisplay = true + } + } + } + /// Toggle the visibility of the line fold decoration. @Invalidating(.display) public var showFoldingRibbon: Bool = true { @@ -145,6 +174,7 @@ public class GutterView: NSView { controller: controller, delegate: delegate ) + separator = configuration.appearance.gutterSeparator } public init( @@ -327,6 +357,7 @@ public class GutterView: NSView { drawBackground(context, dirtyRect: dirtyRect) drawSelectedLines(context) drawLineNumbers(context, dirtyRect: dirtyRect) + drawSeparator(context) context.restoreGState() } @@ -336,3 +367,18 @@ public class GutterView: NSView { textView = nil } } + +private extension GutterView { + func drawSeparator(_ context: CGContext) { + guard let separator, separator.lineWidth > 0 else { return } + context.saveGState() + context.setStrokeColor(separator.color.cgColor) + context.setLineWidth(separator.lineWidth) + context.setLineDash(phase: 0, lengths: separator.dashPattern) + let xPosition = bounds.maxX - separator.lineWidth / 2 + context.move(to: CGPoint(x: xPosition, y: bounds.minY)) + context.addLine(to: CGPoint(x: xPosition, y: bounds.maxY)) + context.strokePath() + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index 6ca03f2a7..05f6c5b16 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -39,6 +39,9 @@ extension SourceEditorConfiguration { /// See ``BracketPairEmphasis`` for more information. Defaults to `.flash`. public var bracketPairEmphasis: BracketPairEmphasis? = .flash + /// The optional vertical separator between the gutter and editor content. + public var gutterSeparator: GutterSeparator? + /// Create a new appearance configuration object. /// - Parameters: /// - theme: The theme for syntax highlighting. @@ -53,6 +56,7 @@ extension SourceEditorConfiguration { /// - tabWidth: The visual tab width in number of spaces. /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. See /// ``BracketPairEmphasis`` for more information. Defaults to `.flash`. + /// - gutterSeparator: The optional vertical separator between the gutter and editor content. public init( theme: EditorTheme, useThemeBackground: Bool = true, @@ -62,7 +66,8 @@ extension SourceEditorConfiguration { wrapLines: Bool, useSystemCursor: Bool = true, tabWidth: Int = 4, - bracketPairEmphasis: BracketPairEmphasis? = .flash + bracketPairEmphasis: BracketPairEmphasis? = .flash, + gutterSeparator: GutterSeparator? = nil ) { self.theme = theme self.useThemeBackground = useThemeBackground @@ -77,6 +82,7 @@ extension SourceEditorConfiguration { } self.tabWidth = tabWidth self.bracketPairEmphasis = bracketPairEmphasis + self.gutterSeparator = gutterSeparator } @MainActor @@ -123,6 +129,8 @@ extension SourceEditorConfiguration { controller.emphasizeSelectionPairs() } + controller.gutterView.separator = gutterSeparator + // Cant put these in one if sadly if #available(macOS 14, *) { if oldConfig?.useSystemCursor != useSystemCursor { diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index bef6615bd..6808f2fea 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -458,6 +458,17 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) } + func test_gutterSeparator() { + XCTAssertNil(controller.gutterSeparator) + XCTAssertNil(controller.gutterView.separator) + + let separator = GutterSeparator(color: .systemRed, lineWidth: 2, dashPattern: [1, 1]) + controller.configuration.appearance.gutterSeparator = separator + + XCTAssertEqual(controller.gutterSeparator, separator) + XCTAssertEqual(controller.gutterView.separator, separator) + } + // MARK: Folding Ribbon func test_foldingRibbonToggle() {