Skip to content

Commit e0ceeb1

Browse files
committed
Mask Collapsed Folds, Simplify Reasoning in Draw
1 parent 473a513 commit e0ceeb1

File tree

5 files changed

+160
-83
lines changed

5 files changed

+160
-83
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// NSColor+LightDark.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 6/4/25.
6+
//
7+
8+
import AppKit
9+
10+
extension NSColor {
11+
convenience init(light: NSColor, dark: NSColor) {
12+
self.init(name: nil) { appearance in
13+
return switch appearance.name {
14+
case .aqua:
15+
light
16+
case .darkAqua:
17+
dark
18+
default:
19+
NSColor()
20+
}
21+
}
22+
}
23+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// NSRect+Transform.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 6/4/25.
6+
//
7+
8+
import AppKit
9+
10+
extension NSRect {
11+
func transform(x: CGFloat = 0, y: CGFloat = 0, width: CGFloat = 0, height: CGFloat = 0) -> NSRect {
12+
NSRect(
13+
x: self.origin.x + x,
14+
y: self.origin.y + y,
15+
width: self.width + width,
16+
height: self.height + height
17+
)
18+
}
19+
}

Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift

Lines changed: 64 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,22 @@ extension FoldingRibbonView {
1919

2020
override func draw(_ dirtyRect: NSRect) {
2121
guard let context = NSGraphicsContext.current?.cgContext,
22-
let layoutManager = model?.controller?.textView.layoutManager else {
22+
let layoutManager = model?.controller?.textView.layoutManager,
23+
// Find the visible lines in the rect AppKit is asking us to draw.
24+
let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY),
25+
let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else {
2326
return
2427
}
2528

2629
context.saveGState()
2730
context.clip(to: dirtyRect)
2831

29-
// Find the visible lines in the rect AppKit is asking us to draw.
30-
guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY),
31-
let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else {
32-
return
33-
}
34-
let textRange = rangeStart.range.location..<rangeEnd.range.upperBound
35-
let folds = getDrawingFolds(forTextRange: textRange, layoutManager: layoutManager)
32+
let folds = getDrawingFolds(
33+
forTextRange: rangeStart.range.location..<rangeEnd.range.upperBound,
34+
layoutManager: layoutManager
35+
)
3636
let foldCaps = FoldCapInfo(folds)
37+
3738
for fold in folds.filter({ !$0.fold.isCollapsed }) {
3839
drawFoldMarker(
3940
fold,
@@ -101,14 +102,15 @@ extension FoldingRibbonView {
101102
}
102103
}
103104

105+
// MARK: - Draw Fold Marker
106+
104107
/// Draw a single fold marker for a fold.
105108
///
106109
/// Ensure the correct fill color is set on the drawing context before calling.
107110
///
108111
/// - Parameters:
109112
/// - foldInfo: The fold to draw.
110-
/// - markerContext: The context in which the fold is being drawn, including the depth and if a line is
111-
/// being hovered.
113+
/// - foldCaps:
112114
/// - context: The drawing context to use.
113115
/// - layoutManager: A layout manager used to retrieve position information for lines.
114116
private func drawFoldMarker(
@@ -119,21 +121,27 @@ extension FoldingRibbonView {
119121
) {
120122
let minYPosition = foldInfo.startLine.yPos
121123
let maxYPosition = foldInfo.endLine.yPos + foldInfo.endLine.height
124+
let foldRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2)
122125

123126
if foldInfo.fold.isCollapsed {
124-
drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context)
125-
} else if let hoveringFold, hoveringFold.isHoveringEqual(foldInfo.fold) {
126-
drawHoveredFold(
127+
drawCollapsedFold(
128+
foldInfo: foldInfo,
127129
minYPosition: minYPosition,
128130
maxYPosition: maxYPosition,
129131
in: context
130132
)
133+
} else if hoveringFold?.isHoveringEqual(foldInfo.fold) == true {
134+
drawHoveredFold(
135+
foldInfo: foldInfo,
136+
foldCaps: foldCaps,
137+
foldRect: foldRect,
138+
in: context
139+
)
131140
} else {
132141
drawNestedFold(
133142
foldInfo: foldInfo,
134143
foldCaps: foldCaps,
135-
minYPosition: minYPosition,
136-
maxYPosition: maxYPosition,
144+
foldRect: foldCaps.adjustFoldRect(using: foldInfo, rect: foldRect),
137145
in: context
138146
)
139147
}
@@ -142,13 +150,14 @@ extension FoldingRibbonView {
142150
// MARK: - Collapsed Fold
143151

144152
private func drawCollapsedFold(
153+
foldInfo: DrawingFoldInfo,
145154
minYPosition: CGFloat,
146155
maxYPosition: CGFloat,
147156
in context: CGContext
148157
) {
149158
context.saveGState()
150159

151-
let fillRect = CGRect(x: 0, y: minYPosition, width: Self.width, height: maxYPosition - minYPosition)
160+
let fillRect = CGRect(x: 0, y: minYPosition + 1.0, width: Self.width, height: maxYPosition - minYPosition - 2.0)
152161

153162
let height = 5.0
154163
let minX = 2.0
@@ -162,6 +171,11 @@ extension FoldingRibbonView {
162171
chevron.addLine(to: CGPoint(x: maxX, y: centerY))
163172
chevron.addLine(to: CGPoint(x: minX, y: maxY))
164173

174+
if let hoveringFoldMask, hoveringFoldMask.intersects(CGPath(rect: fillRect, transform: .none)) {
175+
context.addPath(hoveringFoldMask)
176+
context.clip()
177+
}
178+
165179
context.setStrokeColor(foldedIndicatorChevronColor)
166180
context.setLineCap(.round)
167181
context.setLineJoin(.round)
@@ -178,22 +192,35 @@ extension FoldingRibbonView {
178192
// MARK: - Hovered Fold
179193

180194
private func drawHoveredFold(
181-
minYPosition: CGFloat,
182-
maxYPosition: CGFloat,
195+
foldInfo: DrawingFoldInfo,
196+
foldCaps: FoldCapInfo,
197+
foldRect: NSRect,
183198
in context: CGContext
184199
) {
185200
context.saveGState()
186-
let plainRect = NSRect(x: -2, y: minYPosition, width: 11.0, height: maxYPosition - minYPosition)
187-
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 11.0 / 2, yRadius: 11.0 / 2)
201+
let plainRect = foldRect.transform(x: -2.0, y: -1.0, width: 4.0, height: 2.0)
202+
let roundedRect = NSBezierPath(
203+
roundedRect: plainRect,
204+
xRadius: plainRect.width / 2,
205+
yRadius: plainRect.width / 2
206+
)
188207

189208
context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor)
190209
context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? hoverBorderColor)
191210
context.addPath(roundedRect.cgPathFallback)
192211
context.drawPath(using: .fillStroke)
193212

194-
// Add the little arrows
195-
drawChevron(in: context, yPosition: minYPosition + 8, pointingUp: false)
196-
drawChevron(in: context, yPosition: maxYPosition - 8, pointingUp: true)
213+
// Add the little arrows if we're not hovering right on a collapsed guy
214+
if foldCaps.hoveredFoldShouldDrawTopChevron(foldInfo) {
215+
drawChevron(in: context, yPosition: plainRect.minY + 8, pointingUp: false)
216+
}
217+
if foldCaps.hoveredFoldShouldDrawBottomChevron(foldInfo) {
218+
drawChevron(in: context, yPosition: plainRect.maxY - 8, pointingUp: true)
219+
}
220+
221+
let plainMaskRect = foldRect.transform(y: 1.0, height: -2.0)
222+
let roundedMaskRect = NSBezierPath(roundedRect: plainMaskRect, xRadius: Self.width / 2, yRadius: Self.width / 2)
223+
hoveringFoldMask = roundedMaskRect.cgPathFallback
197224

198225
context.restoreGState()
199226
}
@@ -207,7 +234,11 @@ extension FoldingRibbonView {
207234
let minX = center - (chevronSize.width / 2)
208235
let maxX = center + (chevronSize.width / 2)
209236

210-
let startY = pointingUp ? yPosition + chevronSize.height : yPosition - chevronSize.height
237+
let startY = if pointingUp {
238+
yPosition + chevronSize.height
239+
} else {
240+
yPosition - chevronSize.height
241+
}
211242

212243
context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor)
213244
context.setLineCap(.round)
@@ -228,21 +259,15 @@ extension FoldingRibbonView {
228259
private func drawNestedFold(
229260
foldInfo: DrawingFoldInfo,
230261
foldCaps: FoldCapInfo,
231-
minYPosition: CGFloat,
232-
maxYPosition: CGFloat,
262+
foldRect: NSRect,
233263
in context: CGContext
234264
) {
235265
context.saveGState()
236-
let plainRect = foldCaps.adjustFoldRect(
237-
using: foldInfo,
238-
rect: NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2)
239-
)
240-
let radius = plainRect.width / 2.0
241266
let roundedRect = NSBezierPath(
242-
roundingRect: plainRect,
267+
roundingRect: foldRect,
243268
capTop: foldCaps.foldNeedsTopCap(foldInfo),
244269
capBottom: foldCaps.foldNeedsBottomCap(foldInfo),
245-
cornerRadius: radius
270+
cornerRadius: foldRect.width / 2.0
246271
)
247272

248273
context.setFillColor(markerColor)
@@ -254,8 +279,8 @@ extension FoldingRibbonView {
254279
drawOutline(
255280
foldInfo: foldInfo,
256281
foldCaps: foldCaps,
282+
foldRect: foldRect,
257283
originalPath: roundedRect.cgPathFallback,
258-
yPosition: minYPosition...maxYPosition,
259284
in: context
260285
)
261286
}
@@ -277,42 +302,26 @@ extension FoldingRibbonView {
277302
private func drawOutline(
278303
foldInfo: DrawingFoldInfo,
279304
foldCaps: FoldCapInfo,
305+
foldRect: NSRect,
280306
originalPath: CGPath,
281-
yPosition: ClosedRange<CGFloat>,
282307
in context: CGContext
283308
) {
284309
context.saveGState()
285310

286-
let plainRect = foldCaps.adjustFoldRect(
287-
using: foldInfo,
288-
rect: NSRect(
289-
x: -0.5,
290-
y: yPosition.lowerBound,
291-
width: frame.width + 1.0,
292-
height: yPosition.upperBound - yPosition.lowerBound
293-
)
294-
)
295-
let radius = plainRect.width / 2.0
311+
let plainRect = foldRect.transform(x: -1.0, y: -1.0, width: 2.0, height: 2.0)
296312
let roundedRect = NSBezierPath(
297313
roundingRect: plainRect,
298314
capTop: foldCaps.foldNeedsTopCap(foldInfo),
299315
capBottom: foldCaps.foldNeedsBottomCap(foldInfo),
300-
cornerRadius: radius
316+
cornerRadius: plainRect.width / 2.0
301317
)
302-
roundedRect.transform(using: .init(translationByX: -0.5, byY: 0.0))
318+
roundedRect.transform(using: .init(translationByX: -1.0, byY: 0.0))
303319

304320
let combined = CGMutablePath()
305321
combined.addPath(roundedRect.cgPathFallback)
306322
combined.addPath(originalPath)
307323

308-
context.clip(
309-
to: CGRect(
310-
x: 0,
311-
y: yPosition.lowerBound,
312-
width: 7,
313-
height: yPosition.upperBound - yPosition.lowerBound
314-
)
315-
)
324+
context.clip(to: foldRect.transform(y: -1.0, height: 2.0))
316325
context.addPath(combined)
317326
context.setFillColor(markerBorderColor)
318327
context.drawPath(using: .eoFill)

Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,50 @@ import AppKit
99

1010
extension FoldingRibbonView {
1111
/// A helper type that determines if a fold should be drawn with a cap on the top or bottom if
12-
/// there's an adjacent fold on the same text line. It also provides a helper method
12+
/// there's an adjacent fold on the same text line. It also provides a helper method to adjust fold rects using
13+
/// the cap information.
1314
struct FoldCapInfo {
14-
let startIndices: Set<Int>
15-
let endIndices: Set<Int>
15+
private let startIndices: Set<Int>
16+
private let endIndices: Set<Int>
17+
private let collapsedStartIndices: Set<Int>
18+
private let collapsedEndIndices: Set<Int>
1619

1720
init(_ folds: [DrawingFoldInfo]) {
18-
self.startIndices = folds.reduce(into: Set<Int>(), { $0.insert($1.startLine.index) })
19-
self.endIndices = folds.reduce(into: Set<Int>(), { $0.insert($1.endLine.index) })
21+
var startIndices = Set<Int>()
22+
var endIndices = Set<Int>()
23+
var collapsedStartIndices = Set<Int>()
24+
var collapsedEndIndices = Set<Int>()
25+
26+
for fold in folds {
27+
if fold.fold.isCollapsed {
28+
collapsedStartIndices.insert(fold.startLine.index)
29+
collapsedEndIndices.insert(fold.endLine.index)
30+
} else {
31+
startIndices.insert(fold.startLine.index)
32+
endIndices.insert(fold.endLine.index)
33+
}
34+
}
35+
36+
self.startIndices = startIndices
37+
self.endIndices = endIndices
38+
self.collapsedStartIndices = collapsedStartIndices
39+
self.collapsedEndIndices = collapsedEndIndices
2040
}
2141

2242
func foldNeedsTopCap(_ fold: DrawingFoldInfo) -> Bool {
23-
endIndices.contains(fold.startLine.index)
43+
endIndices.contains(fold.startLine.index) || collapsedEndIndices.contains(fold.startLine.index)
2444
}
2545

2646
func foldNeedsBottomCap(_ fold: DrawingFoldInfo) -> Bool {
27-
startIndices.contains(fold.endLine.index)
47+
startIndices.contains(fold.endLine.index) || collapsedStartIndices.contains(fold.endLine.index)
48+
}
49+
50+
func hoveredFoldShouldDrawTopChevron(_ fold: DrawingFoldInfo) -> Bool {
51+
!collapsedEndIndices.contains(fold.startLine.index)
52+
}
53+
54+
func hoveredFoldShouldDrawBottomChevron(_ fold: DrawingFoldInfo) -> Bool {
55+
!collapsedStartIndices.contains(fold.endLine.index)
2856
}
2957

3058
func adjustFoldRect(
@@ -33,13 +61,17 @@ extension FoldingRibbonView {
3361
) -> NSRect {
3462
let capTop = foldNeedsTopCap(fold)
3563
let capBottom = foldNeedsBottomCap(fold)
36-
let yDelta = capTop ? fold.startLine.height / 2.0 : 0.0
64+
let yDelta: CGFloat = if capTop && !collapsedEndIndices.contains(fold.startLine.index) {
65+
fold.startLine.height / 2.0
66+
} else {
67+
0.0
68+
}
3769

3870
var heightDelta: CGFloat = 0.0
39-
if capTop {
71+
if capTop && !collapsedEndIndices.contains(fold.startLine.index) {
4072
heightDelta -= fold.startLine.height / 2.0
4173
}
42-
if capBottom {
74+
if capBottom && !collapsedStartIndices.contains(fold.endLine.index) {
4375
heightDelta -= fold.endLine.height / 2.0
4476
}
4577

0 commit comments

Comments
 (0)