Skip to content

Commit f4d4808

Browse files
committed
Emphasize Brackets Surrounding Folds
1 parent e0ceeb1 commit f4d4808

File tree

8 files changed

+151
-64
lines changed

8 files changed

+151
-64
lines changed

Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,6 @@ import TextFormation
1111
import TextStory
1212

1313
extension TextViewController {
14-
15-
internal enum BracketPairs {
16-
static let allValues: [(String, String)] = [
17-
("{", "}"),
18-
("[", "]"),
19-
("(", ")"),
20-
("\"", "\""),
21-
("'", "'")
22-
]
23-
24-
static let emphasisValues: [(String, String)] = [
25-
("{", "}"),
26-
("[", "]"),
27-
("(", ")")
28-
]
29-
}
30-
3114
// MARK: - Filter Configuration
3215

3316
/// Initializes any filters for text editing.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// BracketPairs.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 6/5/25.
6+
//
7+
8+
enum BracketPairs {
9+
static let allValues: [(String, String)] = [
10+
("{", "}"),
11+
("[", "]"),
12+
("(", ")"),
13+
("\"", "\""),
14+
("'", "'")
15+
]
16+
17+
static let emphasisValues: [(String, String)] = [
18+
("{", "}"),
19+
("[", "]"),
20+
("(", ")")
21+
]
22+
23+
/// Checks if the given string is a matchable emphasis string.
24+
/// - Parameter potentialMatch: The string to check for matches.
25+
/// - Returns: True if a match was found with either start or end bracket pairs.
26+
static func matches(_ potentialMatch: String) -> Bool {
27+
allValues.contains(where: { $0.0 == potentialMatch || $0.1 == potentialMatch })
28+
}
29+
}

Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ extension NSBezierPath {
2222
public static let bottomLeft = Corners(rawValue: 1 << 1)
2323
public static let topRight = Corners(rawValue: 1 << 2)
2424
public static let bottomRight = Corners(rawValue: 1 << 3)
25+
26+
public static let all: Corners = Corners(rawValue: 0b1111)
2527
}
2628

2729
// swiftlint:disable:next function_body_length

Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import Combine
1919
/// - Loop through the list, creating nested folds as indents go up and down.
2020
///
2121
class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject {
22+
static let emphasisId = "lineFolding"
23+
2224
/// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent``
2325
/// and ``FoldRange/subFolds``.
2426
@Published var foldCache: LineFoldStorage = LineFoldStorage(documentLength: 0)
@@ -92,4 +94,35 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject {
9294
}
9395
return deepestFold
9496
}
97+
98+
func emphasizeBracketsForFold(_ fold: FoldRange) {
99+
clearEmphasis()
100+
101+
// Find the text object, make sure there's available characters around the fold.
102+
guard let text = controller?.textView.textStorage.string as? NSString,
103+
fold.range.lowerBound > 0 && fold.range.upperBound < text.length - 1 else {
104+
return
105+
}
106+
107+
let firstRange = NSRange(location: fold.range.lowerBound - 1, length: 1)
108+
let secondRange = NSRange(location: fold.range.upperBound, length: 1)
109+
110+
// Check if these are emphasizable bracket pairs.
111+
guard BracketPairs.matches(text.substring(from: firstRange) ?? "")
112+
&& BracketPairs.matches(text.substring(from: secondRange) ?? "") else {
113+
return
114+
}
115+
116+
controller?.textView.emphasisManager?.addEmphases(
117+
[
118+
Emphasis(range: firstRange, style: .standard, flash: false, inactive: false, selectInDocument: false),
119+
Emphasis(range: secondRange, style: .standard, flash: false, inactive: false, selectInDocument: false),
120+
],
121+
for: Self.emphasisId
122+
)
123+
}
124+
125+
func clearEmphasis() {
126+
controller?.textView.emphasisManager?.removeEmphases(for: Self.emphasisId)
127+
}
95128
}

Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,40 @@ import CodeEditTextView
1010

1111
class LineFoldPlaceholder: TextAttachment {
1212
let fold: FoldRange
13+
let charWidth: CGFloat
14+
var isSelected: Bool = false
1315

14-
init(fold: FoldRange) {
16+
init(fold: FoldRange, charWidth: CGFloat) {
1517
self.fold = fold
18+
self.charWidth = charWidth
1619
}
1720

18-
var width: CGFloat { 17 }
21+
var width: CGFloat {
22+
charWidth * 5
23+
}
1924

2025
func draw(in context: CGContext, rect: NSRect) {
2126
context.saveGState()
2227

2328
let centerY = rect.midY - 1.5
2429

30+
if isSelected {
31+
context.setFillColor(NSColor.controlAccentColor.cgColor)
32+
context.addPath(
33+
NSBezierPath(
34+
rect: rect.transform(x: 2.0, y: 3.0, width: -4.0, height: -6.0 ),
35+
roundedCorners: .all,
36+
cornerRadius: 2
37+
).cgPathFallback
38+
)
39+
context.fillPath()
40+
}
41+
2542
context.setFillColor(NSColor.secondaryLabelColor.cgColor)
26-
context.addEllipse(in: CGRect(x: rect.minX + 2, y: centerY, width: 3, height: 3))
27-
context.addEllipse(in: CGRect(x: rect.minX + 7, y: centerY, width: 3, height: 3))
28-
context.addEllipse(in: CGRect(x: rect.minX + 12, y: centerY, width: 3, height: 3))
43+
let size = charWidth / 2
44+
context.addEllipse(in: CGRect(x: rect.minX + charWidth * 1.25, y: centerY, width: size, height: size))
45+
context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 2.25), y: centerY, width: size, height: size))
46+
context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 3.25), y: centerY, width: size, height: size))
2947
context.fillPath()
3048

3149
context.restoreGState()

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ extension FoldingRibbonView {
130130
maxYPosition: maxYPosition,
131131
in: context
132132
)
133-
} else if hoveringFold?.isHoveringEqual(foldInfo.fold) == true {
133+
} else if hoveringFold.fold?.isHoveringEqual(foldInfo.fold) == true {
134134
drawHoveredFold(
135135
foldInfo: foldInfo,
136136
foldCaps: foldCaps,
@@ -171,7 +171,8 @@ extension FoldingRibbonView {
171171
chevron.addLine(to: CGPoint(x: maxX, y: centerY))
172172
chevron.addLine(to: CGPoint(x: minX, y: maxY))
173173

174-
if let hoveringFoldMask, hoveringFoldMask.intersects(CGPath(rect: fillRect, transform: .none)) {
174+
if let hoveringFoldMask = hoveringFold.foldMask,
175+
hoveringFoldMask.intersects(CGPath(rect: fillRect, transform: .none)) {
175176
context.addPath(hoveringFoldMask)
176177
context.clip()
177178
}
@@ -205,8 +206,8 @@ extension FoldingRibbonView {
205206
yRadius: plainRect.width / 2
206207
)
207208

208-
context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor)
209-
context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? hoverBorderColor)
209+
context.setFillColor(hoverFillColor.copy(alpha: hoveringFold.progress) ?? hoverFillColor)
210+
context.setStrokeColor(hoverBorderColor.copy(alpha: hoveringFold.progress) ?? hoverBorderColor)
210211
context.addPath(roundedRect.cgPathFallback)
211212
context.drawPath(using: .fillStroke)
212213

@@ -220,7 +221,7 @@ extension FoldingRibbonView {
220221

221222
let plainMaskRect = foldRect.transform(y: 1.0, height: -2.0)
222223
let roundedMaskRect = NSBezierPath(roundedRect: plainMaskRect, xRadius: Self.width / 2, yRadius: Self.width / 2)
223-
hoveringFoldMask = roundedMaskRect.cgPathFallback
224+
hoveringFold.foldMask = roundedMaskRect.cgPathFallback
224225

225226
context.restoreGState()
226227
}
@@ -240,7 +241,7 @@ extension FoldingRibbonView {
240241
yPosition - chevronSize.height
241242
}
242243

243-
context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor)
244+
context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoveringFold.progress).cgColor)
244245
context.setLineCap(.round)
245246
context.setLineJoin(.round)
246247
context.setLineWidth(1.3)

Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ import CodeEditTextView
1313
///
1414
/// This view draws its contents
1515
class FoldingRibbonView: NSView {
16+
struct HoverAnimationDetails: Equatable {
17+
var fold: FoldRange? = nil
18+
var foldMask: CGPath?
19+
var timer: Timer?
20+
var progress: CGFloat = 0.0
21+
22+
static let empty = HoverAnimationDetails()
23+
24+
public static func == (_ lhs: HoverAnimationDetails, _ rhs: HoverAnimationDetails) -> Bool {
25+
lhs.fold == rhs.fold && lhs.foldMask == rhs.foldMask && lhs.progress == rhs.progress
26+
}
27+
}
1628

1729
#warning("Replace before release")
1830
private static let demoFoldProvider = IndentationLineFoldProvider()
@@ -21,13 +33,8 @@ class FoldingRibbonView: NSView {
2133

2234
var model: LineFoldingModel?
2335

24-
// Disabling this lint rule because this initial value is required for @Invalidating
25-
@Invalidating(.display)
26-
var hoveringFold: FoldRange? = nil // swiftlint:disable:this redundant_optional_initialization
27-
var hoveringFoldMask: CGPath?
28-
var hoverAnimationTimer: Timer?
2936
@Invalidating(.display)
30-
var hoverAnimationProgress: CGFloat = 0.0
37+
var hoveringFold: HoverAnimationDetails = .empty
3138

3239
@Invalidating(.display)
3340
var backgroundColor: NSColor = NSColor.controlBackgroundColor
@@ -129,7 +136,7 @@ class FoldingRibbonView: NSView {
129136
layoutManager.attachments.remove(atOffset: attachment.range.location)
130137
attachments.removeAll(where: { $0 === attachment.attachment })
131138
} else {
132-
let placeholder = LineFoldPlaceholder(fold: fold)
139+
let placeholder = LineFoldPlaceholder(fold: fold, charWidth: model?.controller?.fontCharWidth ?? 1.0)
133140
layoutManager.attachments.add(placeholder, for: NSRange(fold.range))
134141
attachments.append(placeholder)
135142
}
@@ -156,47 +163,58 @@ class FoldingRibbonView: NSView {
156163
guard let lineNumber = model?.controller?.textView.layoutManager.textLineForPosition(pointInView.y)?.index,
157164
let fold = model?.getCachedFoldAt(lineNumber: lineNumber),
158165
!fold.isCollapsed else {
159-
hoverAnimationProgress = 0.0
160-
hoveringFold = nil
161-
hoveringFoldMask = nil
166+
clearHoveredFold()
162167
return
163168
}
164169

165-
guard fold.range != hoveringFold?.range else {
170+
guard fold.range != hoveringFold.fold?.range else {
166171
return
167172
}
168-
hoverAnimationTimer?.invalidate()
173+
174+
setHoveredFold(fold: fold)
175+
}
176+
177+
override func mouseExited(with event: NSEvent) {
178+
super.mouseExited(with: event)
179+
clearHoveredFold()
180+
}
181+
182+
/// Clears the current hovered fold. Does not animate.
183+
func clearHoveredFold() {
184+
hoveringFold = .empty
185+
model?.clearEmphasis()
186+
}
187+
188+
/// Set the current hovered fold. This method determines when an animation is required and will facilitate it.
189+
/// - Parameter fold: The fold to set as the current hovered fold.
190+
func setHoveredFold(fold: FoldRange) {
191+
defer {
192+
model?.emphasizeBracketsForFold(fold)
193+
}
194+
195+
hoveringFold.timer?.invalidate()
169196
// We only animate the first hovered fold. If the user moves the mouse vertically into other folds we just
170197
// show it immediately.
171-
if hoveringFold == nil {
172-
hoverAnimationProgress = 0.0
173-
hoveringFold = fold
174-
hoveringFoldMask = nil
175-
198+
if hoveringFold.fold == nil {
176199
let duration: TimeInterval = 0.2
177200
let startTime = CACurrentMediaTime()
178-
hoverAnimationTimer = Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [weak self] timer in
179-
guard let self = self else { return }
180-
let now = CACurrentMediaTime()
181-
let time = CGFloat((now - startTime) / duration)
182-
self.hoverAnimationProgress = min(1.0, time)
183-
if self.hoverAnimationProgress >= 1.0 {
184-
timer.invalidate()
201+
202+
hoveringFold = HoverAnimationDetails(
203+
fold: fold,
204+
timer: Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [weak self] timer in
205+
guard let self = self else { return }
206+
let now = CACurrentMediaTime()
207+
let time = CGFloat((now - startTime) / duration)
208+
self.hoveringFold.progress = min(1.0, time)
209+
if self.hoveringFold.progress >= 1.0 {
210+
timer.invalidate()
211+
}
185212
}
186-
}
213+
)
187214
return
188215
}
189216

190217
// Don't animate these
191-
hoverAnimationProgress = 1.0
192-
hoveringFold = fold
193-
hoveringFoldMask = nil
194-
}
195-
196-
override func mouseExited(with event: NSEvent) {
197-
super.mouseExited(with: event)
198-
hoverAnimationProgress = 0.0
199-
hoveringFold = nil
200-
hoveringFoldMask = nil
218+
hoveringFold = HoverAnimationDetails(fold: fold, progress: 1.0)
201219
}
202220
}

Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ extension RangeStore {
110110
newLength = editedRange.length
111111
}
112112

113-
storageUpdated(replacedCharactersIn: storageRange, withCount: newLength)
113+
storageUpdated(
114+
replacedCharactersIn: storageRange.clamped(to: 0..<_guts.count(in: OffsetMetric())),
115+
withCount: newLength
116+
)
114117
}
115118

116119
/// Handles keeping the internal storage in sync with the document.

0 commit comments

Comments
 (0)