Skip to content

Commit 5c813dd

Browse files
committed
Implement Hover State and Animation
1 parent 0b840f6 commit 5c813dd

3 files changed

Lines changed: 281 additions & 124 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//
2+
// FoldingRibbonView.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 5/8/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
extension FoldingRibbonView {
12+
override func draw(_ dirtyRect: NSRect) {
13+
guard let context = NSGraphicsContext.current?.cgContext,
14+
let layoutManager = model.textView?.layoutManager else {
15+
return
16+
}
17+
18+
context.saveGState()
19+
context.clip(to: dirtyRect)
20+
21+
// Find the visible lines in the rect AppKit is asking us to draw.
22+
guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY),
23+
let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else {
24+
return
25+
}
26+
let lineRange = rangeStart.index...rangeEnd.index
27+
28+
context.setFillColor(markerColor)
29+
let folds = model.getFolds(in: lineRange)
30+
for fold in folds {
31+
drawFoldMarker(
32+
fold,
33+
markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0),
34+
in: context,
35+
using: layoutManager
36+
)
37+
}
38+
39+
context.restoreGState()
40+
}
41+
42+
/// Draw a single fold marker for a fold.
43+
///
44+
/// Ensure the correct fill color is set on the drawing context before calling.
45+
///
46+
/// - Parameters:
47+
/// - fold: The fold to draw.
48+
/// - markerContext: The context in which the fold is being drawn, including the depth and if a line is
49+
/// being hovered.
50+
/// - context: The drawing context to use.
51+
/// - layoutManager: A layout manager used to retrieve position information for lines.
52+
private func drawFoldMarker(
53+
_ fold: FoldRange,
54+
markerContext: FoldMarkerDrawingContext,
55+
in context: CGContext,
56+
using layoutManager: TextLayoutManager
57+
) {
58+
guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos,
59+
let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else {
60+
return
61+
}
62+
63+
let maxYPosition = maxPosition.yPos + maxPosition.height
64+
65+
if let hoveringFold,
66+
hoveringFold.depth == markerContext.depth,
67+
fold.lineRange == hoveringFold.range {
68+
drawHoveredFold(
69+
markerContext: markerContext,
70+
minYPosition: minYPosition,
71+
maxYPosition: maxYPosition,
72+
in: context
73+
)
74+
} else {
75+
drawNestedFold(
76+
markerContext: markerContext,
77+
minYPosition: minYPosition,
78+
maxYPosition: maxYPosition,
79+
in: context
80+
)
81+
}
82+
83+
// Draw subfolds
84+
for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) {
85+
drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager)
86+
}
87+
}
88+
89+
private func drawHoveredFold(
90+
markerContext: FoldMarkerDrawingContext,
91+
minYPosition: CGFloat,
92+
maxYPosition: CGFloat,
93+
in context: CGContext
94+
) {
95+
context.saveGState()
96+
let plainRect = NSRect(x: -2, y: minYPosition, width: 11.0, height: maxYPosition - minYPosition)
97+
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 11.0 / 2, yRadius: 11.0 / 2)
98+
99+
context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor)
100+
context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? hoverBorderColor)
101+
context.addPath(roundedRect.cgPathFallback)
102+
context.drawPath(using: .fillStroke)
103+
104+
// Add the little arrows
105+
drawChevron(in: context, yPosition: minYPosition + 8, pointingUp: false)
106+
drawChevron(in: context, yPosition: maxYPosition - 8, pointingUp: true)
107+
108+
context.restoreGState()
109+
}
110+
111+
private func drawChevron(in context: CGContext, yPosition: CGFloat, pointingUp: Bool) {
112+
context.saveGState()
113+
let path = CGMutablePath()
114+
let chevronSize = CGSize(width: 4.0, height: 2.5)
115+
116+
let center = (Self.width / 2)
117+
let minX = center - (chevronSize.width / 2)
118+
let maxX = center + (chevronSize.width / 2)
119+
120+
let startY = pointingUp ? yPosition + chevronSize.height : yPosition - chevronSize.height
121+
122+
context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor)
123+
context.setLineCap(.round)
124+
context.setLineJoin(.round)
125+
context.setLineWidth(1.3)
126+
127+
path.move(to: CGPoint(x: minX, y: startY))
128+
path.addLine(to: CGPoint(x: center, y: yPosition))
129+
path.addLine(to: CGPoint(x: maxX, y: startY))
130+
131+
context.addPath(path)
132+
context.strokePath()
133+
context.restoreGState()
134+
}
135+
136+
private func drawNestedFold(
137+
markerContext: FoldMarkerDrawingContext,
138+
minYPosition: CGFloat,
139+
maxYPosition: CGFloat,
140+
in context: CGContext
141+
) {
142+
let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2)
143+
// TODO: Draw a single horizontal line when folds are adjacent
144+
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5)
145+
146+
context.addPath(roundedRect.cgPathFallback)
147+
context.drawPath(using: .fill)
148+
149+
// Add small white line if we're overlapping with other markers
150+
if markerContext.depth != 0 {
151+
drawOutline(
152+
minYPosition: minYPosition,
153+
maxYPosition: maxYPosition,
154+
originalPath: roundedRect,
155+
in: context
156+
)
157+
}
158+
}
159+
160+
/// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator.
161+
///
162+
/// This function does not change fill colors for the given context.
163+
///
164+
/// - Parameters:
165+
/// - minYPosition: The minimum y position of the rectangle to outline.
166+
/// - maxYPosition: The maximum y position of the rectangle to outline.
167+
/// - originalPath: The original bezier path for the rounded rectangle.
168+
/// - context: The context to draw in.
169+
private func drawOutline(
170+
minYPosition: CGFloat,
171+
maxYPosition: CGFloat,
172+
originalPath: NSBezierPath,
173+
in context: CGContext
174+
) {
175+
context.saveGState()
176+
177+
let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition)
178+
let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4)
179+
180+
let combined = CGMutablePath()
181+
combined.addPath(roundedRect.cgPathFallback)
182+
combined.addPath(originalPath.cgPathFallback)
183+
184+
context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition))
185+
context.addPath(combined)
186+
context.setFillColor(markerBorderColor)
187+
context.drawPath(using: .eoFill)
188+
189+
context.restoreGState()
190+
}
191+
}

0 commit comments

Comments
 (0)