|
| 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 | +} |
0 commit comments