Skip to content

Commit 91ddcec

Browse files
committed
Correct Gutter Padding With Transparency, Dark Mode
1 parent 4a73315 commit 91ddcec

6 files changed

Lines changed: 149 additions & 29 deletions

File tree

Package.resolved

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/CodeEditSourceEditor/Gutter/GutterView.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ public class GutterView: NSView {
5757
@Invalidating(.display)
5858
var backgroundEdgeInsets: EdgeInsets = EdgeInsets(leading: 0, trailing: 8)
5959

60+
/// The leading padding for the folding ribbon from the line numbers.
61+
@Invalidating(.display)
62+
var foldingRibbonPadding: CGFloat = 4
63+
6064
@Invalidating(.display)
6165
var backgroundColor: NSColor? = NSColor.controlBackgroundColor
6266

@@ -101,7 +105,11 @@ public class GutterView: NSView {
101105

102106
/// Syntax helper for determining the required space for the folding ribbon.
103107
private var foldingRibbonWidth: CGFloat {
104-
if foldingRibbon.isHidden { 0.0 } else { FoldingRibbonView.width }
108+
if foldingRibbon.isHidden {
109+
0.0
110+
} else {
111+
FoldingRibbonView.width + foldingRibbonPadding
112+
}
105113
}
106114

107115
/// The gutter's y positions start at the top of the document and increase as it moves down the screen.
@@ -117,7 +125,7 @@ public class GutterView: NSView {
117125
set {
118126
super.frame = newValue
119127
foldingRibbon.frame = NSRect(
120-
x: newValue.width - edgeInsets.trailing - foldingRibbonWidth,
128+
x: newValue.width - edgeInsets.trailing - foldingRibbonWidth + foldingRibbonPadding,
121129
y: 0.0,
122130
width: foldingRibbonWidth,
123131
height: newValue.height
@@ -138,7 +146,7 @@ public class GutterView: NSView {
138146
self.textView = textView
139147
self.delegate = delegate
140148

141-
foldingRibbon = FoldingRibbonView(textView: textView, levelProvider: nil)
149+
foldingRibbon = FoldingRibbonView(textView: textView, foldProvider: nil)
142150

143151
super.init(frame: .zero)
144152
clipsToBounds = true
@@ -196,7 +204,7 @@ public class GutterView: NSView {
196204
private func drawBackground(_ context: CGContext, dirtyRect: NSRect) {
197205
guard let backgroundColor else { return }
198206
let minX = max(backgroundEdgeInsets.leading, dirtyRect.minX)
199-
let maxX = min(frame.width - backgroundEdgeInsets.trailing, dirtyRect.maxX)
207+
let maxX = min(frame.width - backgroundEdgeInsets.trailing - foldingRibbonWidth, dirtyRect.maxX)
200208
let width = maxX - minX
201209

202210
context.saveGState()

Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,47 @@ import Foundation
99
import AppKit
1010
import CodeEditTextView
1111

12+
final class IndentationLineFoldProvider: LineFoldProvider {
13+
func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? {
14+
guard let linePosition = layoutManager.textLineForIndex(lineNumber),
15+
let indentLevel = indentLevelForPosition(linePosition, textStorage: textStorage) else {
16+
return nil
17+
}
18+
19+
// if let precedingLinePosition = layoutManager.textLineForIndex(lineNumber - 1),
20+
// let precedingIndentLevel = indentLevelForPosition(precedingLinePosition, textStorage: textStorage) {
21+
// if precedingIndentLevel > indentLevel {
22+
// return precedingIndentLevel
23+
// }
24+
// }
25+
//
26+
// if let nextLinePosition = layoutManager.textLineForIndex(lineNumber + 1),
27+
// let nextIndentLevel = indentLevelForPosition(nextLinePosition, textStorage: textStorage) {
28+
// if nextIndentLevel > indentLevel {
29+
// return nextIndentLevel
30+
// }
31+
// }
32+
33+
return indentLevel
34+
}
35+
36+
private func indentLevelForPosition(
37+
_ position: TextLineStorage<TextLine>.TextLinePosition,
38+
textStorage: NSTextStorage
39+
) -> Int? {
40+
guard let substring = textStorage.substring(from: position.range) else {
41+
return nil
42+
}
43+
44+
return substring.utf16 // Keep NSString units
45+
.enumerated()
46+
.first(where: { UnicodeScalar($0.element)?.properties.isWhitespace != true })?
47+
.offset
48+
}
49+
}
50+
51+
let buh = IndentationLineFoldProvider()
52+
1253
/// Displays the code folding ribbon in the ``GutterView``.
1354
///
1455
/// This view draws its contents
@@ -22,16 +63,37 @@ class FoldingRibbonView: NSView {
2263
var backgroundColor: NSColor = NSColor.controlBackgroundColor
2364

2465
@Invalidating(.display)
25-
var markerColor = CGColor(gray: 0.0, alpha: 0.1)
66+
var markerColor = NSColor(name: nil) { appearance in
67+
return switch appearance.name {
68+
case .aqua:
69+
NSColor(deviceWhite: 0.0, alpha: 0.1)
70+
case .darkAqua:
71+
NSColor(deviceWhite: 1.0, alpha: 0.1)
72+
default:
73+
NSColor()
74+
}
75+
}.cgColor
76+
77+
@Invalidating(.display)
78+
var markerBorderColor = NSColor(name: nil) { appearance in
79+
return switch appearance.name {
80+
case .aqua:
81+
NSColor(deviceWhite: 1.0, alpha: 0.4)
82+
case .darkAqua:
83+
NSColor(deviceWhite: 0.0, alpha: 0.4)
84+
default:
85+
NSColor()
86+
}
87+
}.cgColor
2688

2789
override public var isFlipped: Bool {
2890
true
2991
}
3092

31-
init(textView: TextView, levelProvider: LineFoldProvider?) {
93+
init(textView: TextView, foldProvider: LineFoldProvider?) {
3294
self.model = LineFoldingModel(
3395
textView: textView,
34-
levelProvider: levelProvider
96+
foldProvider: buh
3597
)
3698
super.init(frame: .zero)
3799
layerContentsRedrawPolicy = .onSetNeedsDisplay
@@ -95,7 +157,7 @@ class FoldingRibbonView: NSView {
95157
let lineRange = rangeStart.index...rangeEnd.index
96158

97159
context.setFillColor(markerColor)
98-
let folds = model.folds(in: lineRange)
160+
let folds = model.getFolds(in: lineRange)
99161
for fold in folds {
100162
drawFoldMarker(
101163
fold,
@@ -107,7 +169,7 @@ class FoldingRibbonView: NSView {
107169

108170
context.restoreGState()
109171
}
110-
172+
111173
/// Draw a single fold marker for a fold.
112174
///
113175
/// Ensure the correct fill color is set on the drawing context before calling.
@@ -158,7 +220,7 @@ class FoldingRibbonView: NSView {
158220
drawFoldMarker(subFold, markerContext: markerContext.increment(), in: context, using: layoutManager)
159221
}
160222
}
161-
223+
162224
/// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator.
163225
///
164226
/// This function does not change fill colors for the given context.
@@ -185,7 +247,7 @@ class FoldingRibbonView: NSView {
185247

186248
context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition))
187249
context.addPath(combined)
188-
context.setFillColor(CGColor(gray: 1.0, alpha: 0.4))
250+
context.setFillColor(markerBorderColor)
189251
context.drawPath(using: .eoFill)
190252

191253
context.restoreGState()

Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,35 +22,40 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate {
2222
/// and ``FoldRange/subFolds``.
2323
private var foldCache: [FoldRange] = []
2424

25-
weak var levelProvider: LineFoldProvider?
25+
weak var foldProvider: LineFoldProvider?
2626
weak var textView: TextView?
2727

28-
init(textView: TextView, levelProvider: LineFoldProvider?) {
28+
init(textView: TextView, foldProvider: LineFoldProvider?) {
2929
self.textView = textView
30-
self.levelProvider = levelProvider
30+
self.foldProvider = foldProvider
3131
super.init()
3232
textView.addStorageDelegate(self)
3333
buildFoldsForDocument()
3434
}
3535

36-
func folds(in lineRange: ClosedRange<Int>) -> [FoldRange] {
36+
func getFolds(in lineRange: ClosedRange<Int>) -> [FoldRange] {
3737
foldCache.filter({ $0.lineRange.overlaps(lineRange) })
3838
}
3939

40+
/// Build out the ``foldCache`` for the entire document.
41+
///
42+
/// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the
43+
/// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in.
4044
func buildFoldsForDocument() {
41-
guard let textView, let levelProvider else { return }
45+
guard let textView, let foldProvider else { return }
4246
foldCache.removeAll(keepingCapacity: true)
4347

4448
var currentFold: FoldRange?
4549
var currentDepth: Int = 0
4650
for linePosition in textView.layoutManager.linesInRange(textView.documentRange) {
47-
guard let foldDepth = levelProvider.foldLevelAtLine(
51+
guard let foldDepth = foldProvider.foldLevelAtLine(
4852
linePosition.index,
4953
layoutManager: textView.layoutManager,
5054
textStorage: textView.textStorage
5155
) else {
5256
continue
5357
}
58+
print(foldDepth, linePosition.index)
5459
// Start a new fold
5560
if foldDepth > currentDepth {
5661
let newFold = FoldRange(
@@ -59,7 +64,6 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate {
5964
parent: currentFold,
6065
subFolds: []
6166
)
62-
6367
if currentDepth == 0 {
6468
foldCache.append(newFold)
6569
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// LineFoldingModelTests.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 5/8/25.
6+
//
7+
8+
import Testing
9+
import AppKit
10+
import CodeEditTextView
11+
@testable import CodeEditSourceEditor
12+
13+
@Suite
14+
@MainActor
15+
struct LineFoldingModelTests {
16+
/// Makes a fold pattern that increases until halfway through the document then goes back to zero.
17+
class HillPatternFoldProvider: LineFoldProvider {
18+
func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? {
19+
let halfLineCount = (layoutManager.lineCount / 2) - 1
20+
21+
return if lineNumber > halfLineCount {
22+
layoutManager.lineCount - 2 - lineNumber
23+
} else {
24+
lineNumber
25+
}
26+
}
27+
}
28+
29+
let textView: TextView
30+
let model: LineFoldingModel
31+
32+
init() {
33+
textView = TextView(string: "A\nB\nC\nD\nE\nF\n")
34+
textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000)
35+
textView.updatedViewport(NSRect(x: 0, y: 0, width: 1000, height: 1000))
36+
model = LineFoldingModel(textView: textView, foldProvider: nil)
37+
}
38+
39+
/// A little unintuitive but we only expect two folds with this. Our provider goes 0-1-2-2-1-0, but we don't
40+
/// make folds for indent level 0. We also expect folds to start on the lines *before* the indent increases and
41+
/// after it decreases, so the fold covers the start/end of the region being folded.
42+
@Test
43+
func buildFoldsForDocument() throws {
44+
let provider = HillPatternFoldProvider()
45+
model.foldProvider = provider
46+
47+
model.buildFoldsForDocument()
48+
49+
let fold = try #require(model.getFolds(in: 0...5).first)
50+
#expect(fold.lineRange == 0...5)
51+
52+
let innerFold = try #require(fold.subFolds.first)
53+
#expect(innerFold.lineRange == 1...4)
54+
}
55+
}

Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,12 +484,12 @@ final class TextViewControllerTests: XCTestCase {
484484
controller.showFoldingRibbon = false
485485
XCTAssertFalse(controller.gutterView.showFoldingRibbon)
486486
controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass
487-
let noRibbonWidth = controller.gutterView.gutterWidth
487+
let noRibbonWidth = controller.gutterView.frame.width
488488

489489
controller.showFoldingRibbon = true
490490
XCTAssertTrue(controller.gutterView.showFoldingRibbon)
491491
controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass
492-
XCTAssertEqual(controller.gutterView.gutterWidth, noRibbonWidth + 7.0)
492+
XCTAssertEqual(controller.gutterView.frame.width, noRibbonWidth + 7.0)
493493
}
494494
}
495495

0 commit comments

Comments
 (0)