Skip to content

Commit 886c7fe

Browse files
committed
Merge branch 'feat/code-folding' into code-folding/better-folding-calculation
2 parents 6cbf5e0 + 61e5f5a commit 886c7fe

5 files changed

Lines changed: 437 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// IndentationLineFoldProvider.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 5/8/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
final class IndentationLineFoldProvider: LineFoldProvider {
12+
func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? {
13+
guard let linePosition = layoutManager.textLineForIndex(lineNumber),
14+
let indentLevel = indentLevelForPosition(linePosition, textStorage: textStorage) else {
15+
return nil
16+
}
17+
18+
return indentLevel
19+
}
20+
21+
private func indentLevelForPosition(
22+
_ position: TextLineStorage<TextLine>.TextLinePosition,
23+
textStorage: NSTextStorage
24+
) -> Int? {
25+
guard let substring = textStorage.substring(from: position.range) else {
26+
return nil
27+
}
28+
29+
return substring.utf16 // Keep NSString units
30+
.enumerated()
31+
.first(where: { UnicodeScalar($0.element)?.properties.isWhitespace != true })?
32+
.offset
33+
}
34+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// FoldRange.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 5/7/25.
6+
//
7+
8+
import Foundation
9+
10+
/// Represents a recursive folded range
11+
class FoldRange {
12+
var lineRange: ClosedRange<Int>
13+
var range: NSRange
14+
/// Ordered array of ranges that are nested in this fold.
15+
var subFolds: [FoldRange]
16+
17+
weak var parent: FoldRange?
18+
19+
init(lineRange: ClosedRange<Int>, range: NSRange, parent: FoldRange?, subFolds: [FoldRange]) {
20+
self.lineRange = lineRange
21+
self.range = range
22+
self.subFolds = subFolds
23+
self.parent = parent
24+
}
25+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
//
2+
// FoldingRibbonView.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 5/6/25.
6+
//
7+
8+
import Foundation
9+
import AppKit
10+
import CodeEditTextView
11+
12+
#warning("Replace before release")
13+
fileprivate let demoFoldProvider = IndentationLineFoldProvider()
14+
15+
/// Displays the code folding ribbon in the ``GutterView``.
16+
///
17+
/// This view draws its contents
18+
class FoldingRibbonView: NSView {
19+
static let width: CGFloat = 7.0
20+
21+
private var model: LineFoldingModel
22+
private var hoveringLine: Int?
23+
24+
@Invalidating(.display)
25+
var backgroundColor: NSColor = NSColor.controlBackgroundColor
26+
27+
@Invalidating(.display)
28+
var markerColor = NSColor(name: nil) { appearance in
29+
return switch appearance.name {
30+
case .aqua:
31+
NSColor(deviceWhite: 0.0, alpha: 0.1)
32+
case .darkAqua:
33+
NSColor(deviceWhite: 1.0, alpha: 0.1)
34+
default:
35+
NSColor()
36+
}
37+
}.cgColor
38+
39+
@Invalidating(.display)
40+
var markerBorderColor = NSColor(name: nil) { appearance in
41+
return switch appearance.name {
42+
case .aqua:
43+
NSColor(deviceWhite: 1.0, alpha: 0.4)
44+
case .darkAqua:
45+
NSColor(deviceWhite: 0.0, alpha: 0.4)
46+
default:
47+
NSColor()
48+
}
49+
}.cgColor
50+
51+
override public var isFlipped: Bool {
52+
true
53+
}
54+
55+
init(textView: TextView, foldProvider: LineFoldProvider?) {
56+
#warning("Replace before release")
57+
self.model = LineFoldingModel(
58+
textView: textView,
59+
foldProvider: foldProvider ?? demoFoldProvider
60+
)
61+
super.init(frame: .zero)
62+
layerContentsRedrawPolicy = .onSetNeedsDisplay
63+
}
64+
65+
required init?(coder: NSCoder) {
66+
fatalError("init(coder:) has not been implemented")
67+
}
68+
69+
override func updateTrackingAreas() {
70+
trackingAreas.forEach(removeTrackingArea)
71+
let area = NSTrackingArea(
72+
rect: bounds,
73+
options: [.mouseMoved, .activeInKeyWindow],
74+
owner: self,
75+
userInfo: nil
76+
)
77+
addTrackingArea(area)
78+
}
79+
80+
override func mouseMoved(with event: NSEvent) {
81+
let pointInView = convert(event.locationInWindow, from: nil)
82+
hoveringLine = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index
83+
}
84+
85+
/// The context in which the fold is being drawn, including the depth and fold range.
86+
struct FoldMarkerDrawingContext {
87+
let range: ClosedRange<Int>
88+
let depth: UInt
89+
90+
/// Increment the depth
91+
func incrementDepth() -> FoldMarkerDrawingContext {
92+
FoldMarkerDrawingContext(
93+
range: range,
94+
depth: depth + 1
95+
)
96+
}
97+
}
98+
99+
override func draw(_ dirtyRect: NSRect) {
100+
guard let context = NSGraphicsContext.current?.cgContext,
101+
let layoutManager = model.textView?.layoutManager else {
102+
return
103+
}
104+
105+
context.saveGState()
106+
context.clip(to: dirtyRect)
107+
108+
// Find the visible lines in the rect AppKit is asking us to draw.
109+
guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY),
110+
let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else {
111+
return
112+
}
113+
let lineRange = rangeStart.index...rangeEnd.index
114+
115+
context.setFillColor(markerColor)
116+
let folds = model.getFolds(in: lineRange)
117+
for fold in folds {
118+
drawFoldMarker(
119+
fold,
120+
markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0),
121+
in: context,
122+
using: layoutManager
123+
)
124+
}
125+
126+
context.restoreGState()
127+
}
128+
129+
/// Draw a single fold marker for a fold.
130+
///
131+
/// Ensure the correct fill color is set on the drawing context before calling.
132+
///
133+
/// - Parameters:
134+
/// - fold: The fold to draw.
135+
/// - markerContext: The context in which the fold is being drawn, including the depth and if a line is
136+
/// being hovered.
137+
/// - context: The drawing context to use.
138+
/// - layoutManager: A layout manager used to retrieve position information for lines.
139+
private func drawFoldMarker(
140+
_ fold: FoldRange,
141+
markerContext: FoldMarkerDrawingContext,
142+
in context: CGContext,
143+
using layoutManager: TextLayoutManager
144+
) {
145+
guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos,
146+
let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else {
147+
return
148+
}
149+
150+
let maxYPosition = maxPosition.yPos + maxPosition.height
151+
152+
if false /*model.getCachedDepthAt(lineNumber: hoveringLine ?? -1)*/ {
153+
// TODO: Handle hover state
154+
} else {
155+
let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2)
156+
// TODO: Draw a single horizontal line when folds are adjacent
157+
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5)
158+
159+
context.addPath(roundedRect.cgPathFallback)
160+
context.drawPath(using: .fill)
161+
162+
// Add small white line if we're overlapping with other markers
163+
if markerContext.depth != 0 {
164+
drawOutline(
165+
minYPosition: minYPosition,
166+
maxYPosition: maxYPosition,
167+
originalPath: roundedRect,
168+
in: context
169+
)
170+
}
171+
}
172+
173+
// Draw subfolds
174+
for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) {
175+
drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager)
176+
}
177+
}
178+
179+
/// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator.
180+
///
181+
/// This function does not change fill colors for the given context.
182+
///
183+
/// - Parameters:
184+
/// - minYPosition: The minimum y position of the rectangle to outline.
185+
/// - maxYPosition: The maximum y position of the rectangle to outline.
186+
/// - originalPath: The original bezier path for the rounded rectangle.
187+
/// - context: The context to draw in.
188+
private func drawOutline(
189+
minYPosition: CGFloat,
190+
maxYPosition: CGFloat,
191+
originalPath: NSBezierPath,
192+
in context: CGContext
193+
) {
194+
context.saveGState()
195+
196+
let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition)
197+
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4)
198+
199+
let combined = CGMutablePath()
200+
combined.addPath(roundedRect.cgPathFallback)
201+
combined.addPath(originalPath.cgPathFallback)
202+
203+
context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition))
204+
context.addPath(combined)
205+
context.setFillColor(markerBorderColor)
206+
context.drawPath(using: .eoFill)
207+
208+
context.restoreGState()
209+
}
210+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// LineFoldProvider.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 5/7/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
protocol LineFoldProvider: AnyObject {
12+
func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int?
13+
}

0 commit comments

Comments
 (0)