Skip to content

Commit d618ace

Browse files
committed
Toggle Folding State
1 parent f8433f3 commit d618ace

9 files changed

Lines changed: 216 additions & 26 deletions

File tree

Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ extension TextViewController {
143143
- (self?.scrollView.contentInsets.top ?? 0)
144144

145145
self?.gutterView.needsDisplay = true
146+
self?.gutterView.foldingRibbon.needsDisplay = true
146147
self?.guideView?.updatePosition(in: textView)
147148
self?.scrollView.needsLayout = true
148149
}

Sources/CodeEditSourceEditor/Gutter/GutterView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public class GutterView: NSView {
101101
}
102102

103103
/// The view that draws the fold decoration in the gutter.
104-
private var foldingRibbon: FoldingRibbonView
104+
var foldingRibbon: FoldingRibbonView
105105

106106
/// Syntax helper for determining the required space for the folding ribbon.
107107
private var foldingRibbonWidth: CGFloat {

Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import AppKit
99
import CodeEditTextView
1010

1111
final class IndentationLineFoldProvider: LineFoldProvider {
12-
func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int? {
12+
func indentLevelAtLine(substring: NSString) -> Int? {
1313
for idx in 0..<substring.length {
1414
let character = UnicodeScalar(substring.character(at: idx))
1515
if character?.properties.isWhitespace == false {
@@ -18,4 +18,34 @@ final class IndentationLineFoldProvider: LineFoldProvider {
1818
}
1919
return nil
2020
}
21+
22+
func foldLevelAtLine(
23+
lineNumber: Int,
24+
lineRange: NSRange,
25+
currentDepth: Int,
26+
text: NSTextStorage
27+
) -> LineFoldProviderLineInfo? {
28+
guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesWithoutNewlines)?.length,
29+
leadingIndent > 0 else {
30+
return nil
31+
}
32+
33+
if leadingIndent < currentDepth {
34+
// End the fold at the start of whitespace
35+
return .endFold(rangeEnd: lineRange.location + leadingIndent, newDepth: leadingIndent)
36+
}
37+
38+
// Check if the next line has more indent
39+
let maxRange = NSRange(start: lineRange.max, end: text.length)
40+
guard let nextIndent = text.leadingRange(in: maxRange, within: .whitespacesWithoutNewlines)?.length,
41+
nextIndent > 0 else {
42+
return nil
43+
}
44+
45+
if nextIndent > currentDepth, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) {
46+
return .startFold(rangeStart: trailingWhitespace.location, newDepth: nextIndent)
47+
}
48+
49+
return nil
50+
}
2151
}

Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@
88
import AppKit
99
import CodeEditTextView
1010

11+
enum LineFoldProviderLineInfo {
12+
case startFold(rangeStart: Int, newDepth: Int)
13+
case endFold(rangeEnd: Int, newDepth: Int)
14+
15+
var depth: Int {
16+
switch self {
17+
case .startFold(_, let newDepth):
18+
return newDepth
19+
case .endFold(_, let newDepth):
20+
return newDepth
21+
}
22+
}
23+
24+
var rangeIndice: Int {
25+
switch self {
26+
case .startFold(let rangeStart, _):
27+
return rangeStart
28+
case .endFold(let rangeEnd, _):
29+
return rangeEnd
30+
}
31+
}
32+
}
33+
1134
protocol LineFoldProvider: AnyObject {
12-
func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int?
35+
func foldLevelAtLine(
36+
lineNumber: Int,
37+
lineRange: NSRange,
38+
currentDepth: Int,
39+
text: NSTextStorage
40+
) -> LineFoldProviderLineInfo?
1341
}

Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,24 @@ class FoldRange {
1212
var lineRange: ClosedRange<Int>
1313
var range: NSRange
1414
var depth: Int
15+
var collapsed: Bool
1516
/// Ordered array of ranges that are nested in this fold.
1617
var subFolds: [FoldRange]
1718

1819
weak var parent: FoldRange?
1920

20-
init(lineRange: ClosedRange<Int>, range: NSRange, depth: Int, parent: FoldRange?, subFolds: [FoldRange]) {
21+
init(
22+
lineRange: ClosedRange<Int>,
23+
range: NSRange,
24+
depth: Int,
25+
collapsed: Bool,
26+
parent: FoldRange?,
27+
subFolds: [FoldRange]
28+
) {
2129
self.lineRange = lineRange
2230
self.range = range
2331
self.depth = depth
32+
self.collapsed = collapsed
2433
self.subFolds = subFolds
2534
self.parent = parent
2635
}

Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import Combine
1515
/// Fold information is emitted via `rangesPublisher`.
1616
/// Notify the calculator it should re-calculate
1717
class LineFoldCalculator {
18+
private struct LineInfo {
19+
let lineNumber: Int
20+
let providerInfo: LineFoldProviderLineInfo
21+
let collapsed: Bool
22+
}
23+
1824
weak var foldProvider: LineFoldProvider?
1925
weak var textView: TextView?
2026

@@ -48,15 +54,21 @@ class LineFoldCalculator {
4854
var currentDepth: Int = 0
4955
var iterator = textView.layoutManager.linesInRange(textView.documentRange)
5056

51-
var lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider)
57+
var lines = self.getMoreLines(
58+
textView: textView,
59+
iterator: &iterator,
60+
lastDepth: currentDepth,
61+
foldProvider: foldProvider
62+
)
5263
while let lineChunk = lines {
53-
for (lineNumber, foldDepth) in lineChunk {
64+
for lineInfo in lineChunk {
5465
// Start a new fold, going deeper to a new depth.
55-
if foldDepth > currentDepth {
66+
if lineInfo.providerInfo.depth > currentDepth {
5667
let newFold = FoldRange(
57-
lineRange: (lineNumber - 1)...(lineNumber - 1),
58-
range: .zero,
59-
depth: foldDepth,
68+
lineRange: lineInfo.lineNumber...lineInfo.lineNumber,
69+
range: NSRange(location: lineInfo.providerInfo.rangeIndice, length: 0),
70+
depth: lineInfo.providerInfo.depth,
71+
collapsed: lineInfo.collapsed,
6072
parent: currentFold,
6173
subFolds: []
6274
)
@@ -67,24 +79,31 @@ class LineFoldCalculator {
6779
currentFold?.subFolds.append(newFold)
6880
}
6981
currentFold = newFold
70-
} else if foldDepth < currentDepth {
82+
} else if lineInfo.providerInfo.depth < currentDepth {
7183
// End this fold, go shallower "popping" folds deeper than the new depth
72-
while let fold = currentFold, fold.depth > foldDepth {
84+
while let fold = currentFold, fold.depth > lineInfo.providerInfo.depth {
7385
// close this fold at the current line
74-
fold.lineRange = fold.lineRange.lowerBound...lineNumber
86+
fold.lineRange = fold.lineRange.lowerBound...lineInfo.lineNumber
87+
fold.range = NSRange(start: fold.range.location, end: lineInfo.providerInfo.rangeIndice)
7588
// move up
7689
currentFold = fold.parent
7790
}
7891
}
7992

80-
currentDepth = foldDepth
93+
currentDepth = lineInfo.providerInfo.depth
8194
}
82-
lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider)
95+
lines = self.getMoreLines(
96+
textView: textView,
97+
iterator: &iterator,
98+
lastDepth: currentDepth,
99+
foldProvider: foldProvider
100+
)
83101
}
84102

85103
// Clean up any hanging folds.
86104
while let fold = currentFold {
87-
fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount
105+
fold.lineRange = fold.lineRange.lowerBound...textView.layoutManager.lineCount - 1
106+
fold.range = NSRange(start: fold.range.location, end: textView.documentRange.length)
88107
currentFold = fold.parent
89108
}
90109

@@ -95,24 +114,36 @@ class LineFoldCalculator {
95114
private func getMoreLines(
96115
textView: TextView,
97116
iterator: inout TextLayoutManager.RangeIterator,
117+
lastDepth: Int,
98118
foldProvider: LineFoldProvider
99-
) -> [(index: Int, foldDepth: Int)]? {
119+
) -> [LineInfo]? {
100120
DispatchQueue.main.asyncAndWait {
101-
var results: [(index: Int, foldDepth: Int)] = []
121+
var results: [LineInfo] = []
102122
var count = 0
123+
var lastDepth = lastDepth
103124
while count < 50, let linePosition = iterator.next() {
104-
guard textView.textStorage.length <= linePosition.range.max,
105-
let substring = textView.textStorage.substring(from: linePosition.range) as NSString?,
106-
let foldDepth = foldProvider.foldLevelAtLine(
107-
linePosition.index,
108-
substring: substring
109-
) else {
125+
guard let foldInfo = foldProvider.foldLevelAtLine(
126+
lineNumber: linePosition.index,
127+
lineRange: linePosition.range,
128+
currentDepth: lastDepth,
129+
text: textView.textStorage
130+
) else {
110131
count += 1
111132
continue
112133
}
134+
let attachments = textView.layoutManager.attachments
135+
.getAttachmentsOverlapping(linePosition.range)
136+
.compactMap({ $0.attachment as? LineFoldPlaceholder })
113137

114-
results.append((linePosition.index, foldDepth))
138+
results.append(
139+
LineInfo(
140+
lineNumber: linePosition.index,
141+
providerInfo: foldInfo,
142+
collapsed: !attachments.isEmpty
143+
)
144+
)
115145
count += 1
146+
lastDepth = foldInfo.depth
116147
}
117148
if results.isEmpty && count == 0 {
118149
return nil
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// LineFoldPlaceholder.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 5/9/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
class LineFoldPlaceholder: TextAttachment {
12+
var width: CGFloat { 17 }
13+
14+
func draw(in context: CGContext, rect: NSRect) {
15+
context.saveGState()
16+
17+
let centerY = rect.midY - 1.5
18+
19+
context.setFillColor(NSColor.secondaryLabelColor.cgColor)
20+
context.addEllipse(in: CGRect(x: rect.minX + 2, y: centerY, width: 3, height: 3))
21+
context.addEllipse(in: CGRect(x: rect.minX + 7, y: centerY, width: 3, height: 3))
22+
context.addEllipse(in: CGRect(x: rect.minX + 12, y: centerY, width: 3, height: 3))
23+
context.fillPath()
24+
25+
context.restoreGState()
26+
}
27+
}

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ extension FoldingRibbonView {
7676

7777
let maxYPosition = maxPosition.yPos + maxPosition.height
7878

79-
if let hoveringFold,
79+
if fold.collapsed {
80+
drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context)
81+
} else if let hoveringFold,
8082
hoveringFold.depth == markerContext.depth,
8183
fold.lineRange == hoveringFold.range {
8284
drawHoveredFold(
@@ -100,6 +102,39 @@ extension FoldingRibbonView {
100102
}
101103
}
102104

105+
private func drawCollapsedFold(
106+
minYPosition: CGFloat,
107+
maxYPosition: CGFloat,
108+
in context: CGContext
109+
) {
110+
context.saveGState()
111+
112+
let fillRect = CGRect(x: 0, y: minYPosition, width: Self.width, height: maxYPosition - minYPosition)
113+
114+
let height = 5.0
115+
let minX = 2.0
116+
let maxX = Self.width - 2.0
117+
let centerY = minYPosition + (maxYPosition - minYPosition)/2
118+
let minY = centerY - (height/2)
119+
let maxY = centerY + (height/2)
120+
let chevron = CGMutablePath()
121+
122+
chevron.move(to: CGPoint(x: minX, y: minY))
123+
chevron.addLine(to: CGPoint(x: maxX, y: centerY))
124+
chevron.addLine(to: CGPoint(x: minX, y: maxY))
125+
126+
context.setStrokeColor(NSColor.secondaryLabelColor.cgColor)
127+
context.setLineCap(.round)
128+
context.setLineJoin(.round)
129+
context.setLineWidth(1.3)
130+
131+
context.fill(fillRect)
132+
context.addPath(chevron)
133+
context.strokePath()
134+
135+
context.restoreGState()
136+
}
137+
103138
private func drawHoveredFold(
104139
markerContext: FoldMarkerDrawingContext,
105140
minYPosition: CGFloat,

Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ class FoldingRibbonView: NSView {
113113
foldUpdateCancellable?.cancel()
114114
}
115115

116+
override public func resetCursorRects() {
117+
// Don't use an iBeam in this view
118+
addCursorRect(bounds, cursor: .arrow)
119+
}
120+
116121
// MARK: - Hover
117122

118123
override func updateTrackingAreas() {
@@ -126,6 +131,30 @@ class FoldingRibbonView: NSView {
126131
addTrackingArea(area)
127132
}
128133

134+
var attachments: [LineFoldPlaceholder] = []
135+
136+
override func mouseDown(with event: NSEvent) {
137+
let clickPoint = convert(event.locationInWindow, from: nil)
138+
guard event.type == .leftMouseDown,
139+
let lineNumber = model.textView?.layoutManager.textLineForPosition(clickPoint.y)?.index,
140+
let fold = model.getCachedFoldAt(lineNumber: lineNumber) else {
141+
super.mouseDown(with: event)
142+
return
143+
}
144+
if let attachment = model.textView?.layoutManager.attachments.getAttachmentsStartingIn(fold.range.range).first {
145+
model.textView?.layoutManager.attachments.remove(atOffset: attachment.range.location)
146+
fold.range.collapsed = false
147+
attachments.removeAll(where: { $0 === attachment.attachment })
148+
} else {
149+
let placeholder = LineFoldPlaceholder()
150+
model.textView?.layoutManager.attachments.add(placeholder, for: fold.range.range)
151+
attachments.append(placeholder)
152+
fold.range.collapsed = true
153+
}
154+
155+
model.textView?.needsLayout = true
156+
}
157+
129158
override func mouseMoved(with event: NSEvent) {
130159
let pointInView = convert(event.locationInWindow, from: nil)
131160
guard let lineNumber = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index,

0 commit comments

Comments
 (0)