Skip to content

Commit ba30234

Browse files
Add Hover Interaction to Folding Ribbon (#325)
### Description Adds the hover interaction to the code folding ribbon. Details: - Animates in when entering the fold region. - Does not animate when moving between folds after animation. - Hovered lines are emphasized and not transparent. ### Related Issues * #43 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/164e61e9-07c0-4a0c-814d-7a70226e0136 --------- Co-authored-by: Austin Condiff <austin.condiff@gmail.com>
1 parent 61e5f5a commit ba30234

File tree

6 files changed

+314
-145
lines changed

6 files changed

+314
-145
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

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

0 commit comments

Comments
 (0)