Skip to content

Commit bab4c8e

Browse files
committed
Add Straight Line When Adjacent
1 parent 8976911 commit bab4c8e

File tree

9 files changed

+228
-51
lines changed

9 files changed

+228
-51
lines changed

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.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//
2+
// NSBezierPath+RoundedCorners.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 6/3/25.
6+
//
7+
8+
import AppKit
9+
10+
// Wonderful NSBezierPath extension taken with modification from the playground code at:
11+
// https://github.com/janheiermann/BezierPath-Corners
12+
13+
extension NSBezierPath {
14+
struct Corners: OptionSet {
15+
public let rawValue: Int
16+
17+
public init(rawValue: Corners.RawValue) {
18+
self.rawValue = rawValue
19+
}
20+
21+
public static let topLeft = Corners(rawValue: 1 << 0)
22+
public static let bottomLeft = Corners(rawValue: 1 << 1)
23+
public static let topRight = Corners(rawValue: 1 << 2)
24+
public static let bottomRight = Corners(rawValue: 1 << 3)
25+
}
26+
27+
convenience init(rect: CGRect, roundedCorners corners: Corners, cornerRadius: CGFloat) {
28+
self.init()
29+
30+
let maxX = rect.maxX
31+
let minX = rect.minX
32+
let maxY = rect.maxY
33+
let minY = rect.minY
34+
let radius = min(cornerRadius, min(rect.width, rect.height) / 2)
35+
36+
// Start at bottom-left corner
37+
move(to: CGPoint(x: minX + (corners.contains(.bottomLeft) ? radius : 0), y: minY))
38+
39+
// Bottom edge
40+
if corners.contains(.bottomRight) {
41+
line(to: CGPoint(x: maxX - radius, y: minY))
42+
appendArc(
43+
withCenter: CGPoint(x: maxX - radius, y: minY + radius),
44+
radius: radius,
45+
startAngle: 270,
46+
endAngle: 0,
47+
clockwise: false
48+
)
49+
} else {
50+
line(to: CGPoint(x: maxX, y: minY))
51+
}
52+
53+
// Right edge
54+
if corners.contains(.topRight) {
55+
line(to: CGPoint(x: maxX, y: maxY - radius))
56+
appendArc(
57+
withCenter: CGPoint(x: maxX - radius, y: maxY - radius),
58+
radius: radius,
59+
startAngle: 0,
60+
endAngle: 90,
61+
clockwise: false
62+
)
63+
} else {
64+
line(to: CGPoint(x: maxX, y: maxY))
65+
}
66+
67+
// Top edge
68+
if corners.contains(.topLeft) {
69+
line(to: CGPoint(x: minX + radius, y: maxY))
70+
appendArc(
71+
withCenter: CGPoint(x: minX + radius, y: maxY - radius),
72+
radius: radius,
73+
startAngle: 90,
74+
endAngle: 180,
75+
clockwise: false
76+
)
77+
} else {
78+
line(to: CGPoint(x: minX, y: maxY))
79+
}
80+
81+
// Left edge
82+
if corners.contains(.bottomLeft) {
83+
line(to: CGPoint(x: minX, y: minY + radius))
84+
appendArc(
85+
withCenter: CGPoint(x: minX + radius, y: minY + radius),
86+
radius: radius,
87+
startAngle: 180,
88+
endAngle: 270,
89+
clockwise: false
90+
)
91+
} else {
92+
line(to: CGPoint(x: minX, y: minY))
93+
}
94+
95+
close()
96+
}
97+
98+
99+
convenience init(roundingRect: CGRect, capTop: Bool, capBottom: Bool, cornerRadius radius: CGFloat) {
100+
switch (capTop, capBottom) {
101+
case (true, true):
102+
self.init(rect: roundingRect)
103+
case (false, true):
104+
self.init(rect: roundingRect, roundedCorners: [.bottomLeft, .bottomRight], cornerRadius: radius)
105+
case (true, false):
106+
self.init(rect: roundingRect, roundedCorners: [.topLeft, .topRight], cornerRadius: radius)
107+
case (false, false):
108+
self.init(roundedRect: roundingRect, xRadius: radius, yRadius: radius)
109+
}
110+
}
111+
}

Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift renamed to Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Equatable.swift

File renamed without changes.

Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift renamed to Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Helpers.swift

File renamed without changes.

Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift renamed to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+LineHeight.swift

File renamed without changes.

Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift renamed to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+RulerFont.swift

File renamed without changes.

Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ struct FoldRange: Sendable, Equatable {
1515
let depth: Int
1616
let range: Range<Int>
1717
var isCollapsed: Bool
18+
19+
func isHoveringEqual(_ other: FoldRange) -> Bool {
20+
depth == other.depth && range.contains(other.range)
21+
}
1822
}
1923

2024
/// Sendable data model for code folding using RangeStore

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

Lines changed: 102 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,52 @@ import AppKit
99
import CodeEditTextView
1010

1111
extension FoldingRibbonView {
12-
/// The context in which the fold is being drawn, including the depth and fold range.
13-
struct FoldMarkerDrawingContext {
14-
let range: ClosedRange<Int>
15-
let depth: UInt
16-
17-
/// Increment the depth
18-
func incrementDepth() -> FoldMarkerDrawingContext {
19-
FoldMarkerDrawingContext(
20-
range: range,
21-
depth: depth + 1
12+
struct FoldCapInfo {
13+
let startIndices: Set<Int>
14+
let endIndices: Set<Int>
15+
16+
init(_ folds: [DrawingFoldInfo]) {
17+
self.startIndices = folds.reduce(into: Set<Int>(), { $0.insert($1.startLine.index) })
18+
self.endIndices = folds.reduce(into: Set<Int>(), { $0.insert($1.endLine.index) })
19+
}
20+
21+
func foldNeedsTopCap(_ fold: DrawingFoldInfo) -> Bool {
22+
endIndices.contains(fold.startLine.index)
23+
}
24+
25+
func foldNeedsBottomCap(_ fold: DrawingFoldInfo) -> Bool {
26+
startIndices.contains(fold.endLine.index)
27+
}
28+
29+
func adjustFoldRect(
30+
using fold: DrawingFoldInfo,
31+
rect: NSRect
32+
) -> NSRect {
33+
let capTop = foldNeedsTopCap(fold)
34+
let capBottom = foldNeedsBottomCap(fold)
35+
let yDelta = capTop ? fold.startLine.height / 2.0 : 0.0
36+
let heightDelta: CGFloat = if capTop && capBottom {
37+
-fold.startLine.height
38+
} else if capTop || capBottom {
39+
-(fold.startLine.height / 2.0)
40+
} else {
41+
0.0
42+
}
43+
return NSRect(
44+
x: rect.origin.x,
45+
y: rect.origin.y + yDelta,
46+
width: rect.size.width,
47+
height: rect.size.height + heightDelta
2248
)
2349
}
2450
}
2551

52+
struct DrawingFoldInfo {
53+
let fold: FoldRange
54+
let startLine: TextLineStorage<TextLine>.TextLinePosition
55+
let endLine: TextLineStorage<TextLine>.TextLinePosition
56+
}
57+
2658
override func draw(_ dirtyRect: NSRect) {
2759
guard let context = NSGraphicsContext.current?.cgContext,
2860
let layoutManager = model?.controller?.textView.layoutManager else {
@@ -38,19 +70,21 @@ extension FoldingRibbonView {
3870
return
3971
}
4072
let textRange = rangeStart.range.location..<rangeEnd.range.upperBound
41-
42-
let folds = getDrawingFolds(forTextRange: textRange)
43-
for fold in folds.filter({ !$0.isCollapsed }) {
73+
let folds = getDrawingFolds(forTextRange: textRange, layoutManager: layoutManager)
74+
let foldCaps = FoldCapInfo(folds)
75+
for fold in folds.filter({ !$0.fold.isCollapsed }) {
4476
drawFoldMarker(
4577
fold,
78+
foldCaps: foldCaps,
4679
in: context,
4780
using: layoutManager
4881
)
4982
}
5083

51-
for fold in folds.filter({ $0.isCollapsed }) {
84+
for fold in folds.filter({ $0.fold.isCollapsed }) {
5285
drawFoldMarker(
5386
fold,
87+
foldCaps: foldCaps,
5488
in: context,
5589
using: layoutManager
5690
)
@@ -59,7 +93,10 @@ extension FoldingRibbonView {
5993
context.restoreGState()
6094
}
6195

62-
private func getDrawingFolds(forTextRange textRange: Range<Int>) -> [FoldRange] {
96+
private func getDrawingFolds(
97+
forTextRange textRange: Range<Int>,
98+
layoutManager: TextLayoutManager
99+
) -> [DrawingFoldInfo] {
63100
var folds = model?.getFolds(in: textRange) ?? []
64101

65102
// Add in some fake depths, we can draw these underneath the rest of the folds to make it look like it's
@@ -78,44 +115,47 @@ extension FoldingRibbonView {
78115
}
79116
}
80117

81-
return folds
118+
return folds.compactMap { fold in
119+
guard let startLine = layoutManager.textLineForOffset(fold.range.lowerBound),
120+
let endLine = layoutManager.textLineForOffset(fold.range.upperBound) else {
121+
return nil
122+
}
123+
124+
return DrawingFoldInfo(fold: fold, startLine: startLine, endLine: endLine)
125+
}
82126
}
83127

84128
/// Draw a single fold marker for a fold.
85129
///
86130
/// Ensure the correct fill color is set on the drawing context before calling.
87131
///
88132
/// - Parameters:
89-
/// - fold: The fold to draw.
133+
/// - foldInfo: The fold to draw.
90134
/// - markerContext: The context in which the fold is being drawn, including the depth and if a line is
91135
/// being hovered.
92136
/// - context: The drawing context to use.
93137
/// - layoutManager: A layout manager used to retrieve position information for lines.
94138
private func drawFoldMarker(
95-
_ fold: FoldRange,
139+
_ foldInfo: DrawingFoldInfo,
140+
foldCaps: FoldCapInfo,
96141
in context: CGContext,
97142
using layoutManager: TextLayoutManager
98143
) {
99-
guard let minYPosition = layoutManager.textLineForOffset(fold.range.lowerBound)?.yPos,
100-
let maxPosition = layoutManager.textLineForOffset(fold.range.upperBound) else {
101-
return
102-
}
103-
104-
let maxYPosition = maxPosition.yPos + maxPosition.height
144+
let minYPosition = foldInfo.startLine.yPos
145+
let maxYPosition = foldInfo.endLine.yPos + foldInfo.endLine.height
105146

106-
if fold.isCollapsed {
147+
if foldInfo.fold.isCollapsed {
107148
drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context)
108-
} else if let hoveringFold,
109-
hoveringFold.depth == fold.depth,
110-
NSRange(hoveringFold.range).intersection(NSRange(fold.range)) == NSRange(hoveringFold.range) {
149+
} else if let hoveringFold, hoveringFold.isHoveringEqual(foldInfo.fold) {
111150
drawHoveredFold(
112151
minYPosition: minYPosition,
113152
maxYPosition: maxYPosition,
114153
in: context
115154
)
116155
} else {
117156
drawNestedFold(
118-
fold: fold,
157+
foldInfo: foldInfo,
158+
foldCaps: foldCaps,
119159
minYPosition: minYPosition,
120160
maxYPosition: maxYPosition,
121161
in: context
@@ -204,26 +244,37 @@ extension FoldingRibbonView {
204244
}
205245

206246
private func drawNestedFold(
207-
fold: FoldRange,
247+
foldInfo: DrawingFoldInfo,
248+
foldCaps: FoldCapInfo,
208249
minYPosition: CGFloat,
209250
maxYPosition: CGFloat,
210251
in context: CGContext
211252
) {
212253
context.saveGState()
213-
let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2)
214-
// TODO: Draw a single horizontal line when folds are adjacent
215-
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5)
254+
let plainRect = foldCaps.adjustFoldRect(
255+
using: foldInfo,
256+
rect: NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2)
257+
)
258+
let radius = plainRect.width / 2.0
259+
let roundedRect = NSBezierPath(
260+
roundingRect: plainRect,
261+
capTop: foldCaps.foldNeedsTopCap(foldInfo),
262+
capBottom: foldCaps.foldNeedsBottomCap(foldInfo),
263+
cornerRadius: radius
264+
)
216265

217266
context.setFillColor(markerColor)
218267
context.addPath(roundedRect.cgPathFallback)
219268
context.drawPath(using: .fill)
220269

221270
// Add small white line if we're overlapping with other markers
222-
if fold.depth != 0 {
271+
if foldInfo.fold.depth != 0 {
223272
drawOutline(
273+
foldInfo: foldInfo,
274+
foldCaps: foldCaps,
275+
originalPath: roundedRect.cgPathFallback,
224276
minYPosition: minYPosition,
225277
maxYPosition: maxYPosition,
226-
originalPath: roundedRect,
227278
in: context
228279
)
229280
}
@@ -241,19 +292,31 @@ extension FoldingRibbonView {
241292
/// - originalPath: The original bezier path for the rounded rectangle.
242293
/// - context: The context to draw in.
243294
private func drawOutline(
295+
foldInfo: DrawingFoldInfo,
296+
foldCaps: FoldCapInfo,
297+
originalPath: CGPath,
244298
minYPosition: CGFloat,
245299
maxYPosition: CGFloat,
246-
originalPath: NSBezierPath,
247300
in context: CGContext
248301
) {
249302
context.saveGState()
250303

251-
let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition)
252-
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4)
304+
let plainRect = foldCaps.adjustFoldRect(
305+
using: foldInfo,
306+
rect: NSRect(x: -0.5, y: minYPosition, width: frame.width + 1.0, height: maxYPosition - minYPosition)
307+
)
308+
let radius = plainRect.width / 2.0
309+
let roundedRect = NSBezierPath(
310+
roundingRect: plainRect,
311+
capTop: foldCaps.foldNeedsTopCap(foldInfo),
312+
capBottom: foldCaps.foldNeedsBottomCap(foldInfo),
313+
cornerRadius: radius
314+
)
315+
roundedRect.transform(using: .init(translationByX: -0.5, byY: 0.0))
253316

254317
let combined = CGMutablePath()
255318
combined.addPath(roundedRect.cgPathFallback)
256-
combined.addPath(originalPath.cgPathFallback)
319+
combined.addPath(originalPath)
257320

258321
context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition))
259322
context.addPath(combined)

0 commit comments

Comments
 (0)