|
| 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