Files
luma/Luma/Editor/CodeFolding.swift
Ole André Vadla Ravnås 5e81f32cba ui: Fold code and preview colours in the editor
Draw line numbers and the current line's band in a gutter, with fold
chevrons that fade in while the pointer is over it, and a guide down
each open scope. Collapse a folded range behind a placeholder, and put
a swatch before each colour the server reports.
2026-10-01 20:49:11 +02:00

493 lines
22 KiB
Swift

import LumaCore
#if canImport(AppKit)
import AppKit
struct CodeFold: Hashable {
let startLine: Int
let hidden: NSRange
static func folds(from ranges: [LSP.FoldingRange], in text: String) -> [CodeFold] {
let map = LineMap(text: text)
let units = Array(text.utf16)
return ranges.compactMap { range in
var start = map.utf16Offset(of: LSP.Position(line: range.startLine, character: range.startCharacter ?? 0))
var end = map.utf16Offset(of: LSP.Position(line: range.endLine, character: range.endCharacter ?? 0))
if range.startCharacter == nil {
start = map.utf16Offset(of: LSP.Position(line: range.startLine + 1, character: 0)) - 1
}
if range.endCharacter == nil {
end = map.utf16Offset(of: LSP.Position(line: range.endLine + 1, character: 0)) - 1
}
if start < units.count, Self.openers.contains(units[start]) {
start += 1
}
if end > 0, end <= units.count, Self.closers.contains(units[end - 1]) {
end -= 1
}
guard end > start else { return nil }
return CodeFold(startLine: range.startLine, hidden: NSRange(location: start, length: end - start))
}
}
private static let openers: Set<UTF16.CodeUnit> = [0x7B, 0x28, 0x5B]
private static let closers: Set<UTF16.CodeUnit> = [0x7D, 0x29, 0x5D]
}
extension NSColor {
convenience init(_ color: LSP.Color) {
self.init(srgbRed: color.red, green: color.green, blue: color.blue, alpha: color.alpha)
}
}
struct CodeSwatch: Hashable {
let location: Int
let color: NSColor
}
nonisolated final class FoldingLayoutManager: NSLayoutManager, NSLayoutManagerDelegate {
static let swatchSize: CGFloat = 10
static let swatchGap: CGFloat = 4
static let placeholderWidth: CGFloat = 22
static let placeholderGap: CGFloat = 3
static let placeholderKern = placeholderWidth + placeholderGap * 2
override init() {
super.init()
delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("FoldingLayoutManager is not loaded from a nib")
}
func layoutManager(
_ layoutManager: NSLayoutManager,
shouldUse action: NSLayoutManager.ControlCharacterAction,
forControlCharacterAt charIndex: Int
) -> NSLayoutManager.ControlCharacterAction {
isHidden(charIndex) ? .zeroAdvancement : action
}
var hiddenRanges: [NSRange] = [] {
didSet {
guard hiddenRanges != oldValue else { return }
invalidateGlyphs(forCharacterRange: NSRange(location: 0, length: textStorage?.length ?? 0), changeInLength: 0, actualCharacterRange: nil)
invalidateLayout(forCharacterRange: NSRange(location: 0, length: textStorage?.length ?? 0), actualCharacterRange: nil)
}
}
var swatches: [CodeSwatch] = []
private(set) var placeholderRects: [(rect: NSRect, hidden: NSRange)] = []
override func setGlyphs(
_ glyphs: UnsafePointer<CGGlyph>,
properties: UnsafePointer<NSLayoutManager.GlyphProperty>,
characterIndexes: UnsafePointer<Int>,
font: NSFont,
forGlyphRange glyphRange: NSRange
) {
guard !hiddenRanges.isEmpty else {
super.setGlyphs(glyphs, properties: properties, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
return
}
var adjusted = Array(UnsafeBufferPointer(start: properties, count: glyphRange.length))
for index in 0..<glyphRange.length where isHidden(characterIndexes[index]) {
adjusted[index] = .null
}
adjusted.withUnsafeBufferPointer { buffer in
super.setGlyphs(glyphs, properties: buffer.baseAddress!, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}
}
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
drawPlaceholders(at: origin)
drawSwatches(at: origin)
}
private func drawPlaceholders(at origin: NSPoint) {
placeholderRects = []
guard let length = textStorage?.length else { return }
let label = NSAttributedString(string: "\u{22EF}", attributes: [
.font: NSFont.systemFont(ofSize: 9, weight: .semibold),
.foregroundColor: NSColor.secondaryLabelColor,
])
let labelSize = label.size()
for hidden in hiddenRanges where hidden.location > 0 && NSMaxRange(hidden) < length {
let resumed = glyphIndexForCharacter(at: NSMaxRange(hidden))
let line = lineFragmentRect(forGlyphAt: resumed, effectiveRange: nil)
let resumedX = line.minX + location(forGlyphAt: resumed).x
let pill = NSRect(
x: origin.x + resumedX - Self.placeholderGap - Self.placeholderWidth,
y: origin.y + line.midY - labelSize.height / 2 - 1,
width: Self.placeholderWidth, height: labelSize.height + 2
).integral
NSColor.quaternaryLabelColor.setFill()
NSBezierPath(roundedRect: pill, xRadius: 4, yRadius: 4).fill()
label.draw(at: NSPoint(x: pill.midX - labelSize.width / 2, y: pill.minY + 1))
placeholderRects.append((pill, hidden))
}
}
private func drawSwatches(at origin: NSPoint) {
guard let container = textContainers.first, let length = textStorage?.length else { return }
for swatch in swatches where swatch.location < length && !isHidden(swatch.location) {
let glyph = glyphIndexForCharacter(at: swatch.location)
let rect = boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), in: container)
let side = Self.swatchSize
let square = NSRect(x: rect.minX + origin.x - side - Self.swatchGap / 2, y: rect.midY + origin.y - side / 2, width: side, height: side)
swatch.color.setFill()
NSBezierPath(roundedRect: square, xRadius: 2, yRadius: 2).fill()
NSColor.separatorColor.setStroke()
NSBezierPath(roundedRect: square.insetBy(dx: 0.5, dy: 0.5), xRadius: 2, yRadius: 2).stroke()
}
}
func isHidden(_ characterIndex: Int) -> Bool {
hiddenRanges.contains { NSLocationInRange(characterIndex, $0) }
}
}
enum ScopeGuides {
static let color = NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(white: 0.25, alpha: 1) : NSColor(white: 0.827, alpha: 1)
}
static func draw(_ scopes: [LSP.FoldingRange], in textView: NSTextView, dirtyRect: NSRect) {
guard let layoutManager = textView.layoutManager as? FoldingLayoutManager else { return }
let text = textView.string as NSString
let lines = LineMap(text: textView.string)
let origin = textView.textContainerOrigin
color.setFill()
for scope in scopes where scope.kind == nil && scope.endLine - scope.startLine >= 2 {
let opening = lines.utf16Offset(of: LSP.Position(line: scope.startLine, character: 0))
let firstInside = lines.utf16Offset(of: LSP.Position(line: scope.startLine + 1, character: 0))
let lastInside = lines.utf16Offset(of: LSP.Position(line: scope.endLine - 1, character: 0))
guard lastInside < text.length, !layoutManager.isHidden(firstInside) else { continue }
let indent = firstNonBlank(in: text, from: opening)
let indentGlyph = layoutManager.glyphIndexForCharacter(at: indent)
let openingFragment = layoutManager.lineFragmentRect(forGlyphAt: indentGlyph, effectiveRange: nil)
let x = origin.x + openingFragment.minX + layoutManager.location(forGlyphAt: indentGlyph).x
+ inkOffset(ofGlyph: indentGlyph, character: indent, in: layoutManager)
let top = layoutManager.lineFragmentRect(forGlyphAt: layoutManager.glyphIndexForCharacter(at: firstInside), effectiveRange: nil).minY
let bottom = layoutManager.lineFragmentRect(forGlyphAt: layoutManager.glyphIndexForCharacter(at: lastInside), effectiveRange: nil).maxY
let guide = textView.backingAlignedRect(
NSRect(x: x, y: origin.y + top, width: 1, height: bottom - top), options: .alignAllEdgesNearest)
if guide.intersects(dirtyRect) {
guide.fill()
}
}
}
private static func inkOffset(ofGlyph glyph: Int, character: Int, in layoutManager: NSLayoutManager) -> CGFloat {
guard let font = layoutManager.textStorage?.attribute(.font, at: character, effectiveRange: nil) as? NSFont else { return 0 }
return font.boundingRect(forCGGlyph: layoutManager.cgGlyph(at: glyph)).minX
}
private static func firstNonBlank(in text: NSString, from start: Int) -> Int {
var index = start
while index < text.length, text.character(at: index) == 0x20 || text.character(at: index) == 0x09 {
index += 1
}
return index
}
}
enum CurrentLineBand {
static let color = NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(white: 1, alpha: 0.07) : NSColor(white: 0, alpha: 0.07)
}
static func rect(in textView: NSTextView, font: NSFont) -> NSRect? {
let selection = textView.selectedRange()
guard selection.length == 0, let layoutManager = textView.layoutManager else { return nil }
let length = (textView.string as NSString).length
let fragment: NSRect
if selection.location >= length, !layoutManager.extraLineFragmentRect.isEmpty {
fragment = layoutManager.extraLineFragmentRect
} else if length > 0 {
let glyph = layoutManager.glyphIndexForCharacter(at: min(selection.location, length - 1))
fragment = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
} else {
return nil
}
let band = NSRect(
x: 0, y: fragment.minY + textView.textContainerOrigin.y + capsCentering(in: fragment, font: font, layoutManager: layoutManager),
width: textView.bounds.width, height: fragment.height)
return textView.backingAlignedRect(band, options: .alignAllEdgesNearest)
}
private static func capsCentering(in fragment: NSRect, font: NSFont, layoutManager: NSLayoutManager) -> CGFloat {
let baseline = layoutManager.defaultBaselineOffset(for: font)
let spaceAboveCaps = baseline - font.capHeight
let spaceBelowBaseline = fragment.height - baseline
return (spaceAboveCaps - spaceBelowBaseline) / 2
}
}
final class CodeGutterView: NSRulerView {
var onToggleFold: ((Int) -> Void)?
var showsOpenFoldControls = false {
didSet {
guard showsOpenFoldControls != oldValue else { return }
NSAnimationContext.runAnimationGroup { context in
context.duration = showsOpenFoldControls ? 0.15 : 0.4
openFoldControls.animator().alphaValue = showsOpenFoldControls ? 1 : 0
}
}
}
private let textView: CodeTextView
private let openFoldControls = OpenFoldControlsView()
private var foldTargets: [(rect: NSRect, line: Int)] = []
private var digitCount = 0
private var sizedAdvance: CGFloat = 0
private static let leadingInset: CGFloat = 18
private static let bandInset: CGFloat = 4
private static let numberToChevron: CGFloat = 1.26
private static let chevronWidth: CGFloat = 1.41
private static let chevronHeight: CGFloat = 0.72
private static let chevronStroke: CGFloat = 0.2
private static let chevronToCode: CGFloat = 0.55
private static let chevronDrop: CGFloat = 0.5
init(scrollView: NSScrollView, textView: CodeTextView) {
self.textView = textView
super.init(scrollView: scrollView, orientation: .verticalRuler)
clientView = textView
clipsToBounds = true
openFoldControls.frame = bounds
openFoldControls.autoresizingMask = [.width, .height]
openFoldControls.wantsLayer = true
openFoldControls.alphaValue = 0
addSubview(openFoldControls)
}
@available(*, unavailable)
required init(coder: NSCoder) {
fatalError("CodeGutterView is not loaded from a nib")
}
func refresh() {
let digits = max(2, String(lineCount).count)
if digits != digitCount || advance != sizedAdvance {
digitCount = digits
sizedAdvance = advance
let textStart = textView.textContainerInset.width + (textView.textContainer?.lineFragmentPadding ?? 0)
let chevronToText = max(Self.chevronToCode * advance - textStart, 0)
let previousThickness = ruleThickness
ruleThickness = (chevronColumnMinX + Self.chevronWidth * advance + chevronToText).rounded(.up)
keepTextClear(ofPreviousThickness: previousThickness)
}
needsDisplay = true
}
private func keepTextClear(ofPreviousThickness previousThickness: CGFloat) {
guard let scrollView, scrollView.verticalRulerView === self else { return }
let clip = scrollView.contentView
var origin = clip.bounds.origin
origin.x = origin.x <= 0 ? -ruleThickness : origin.x - (ruleThickness - previousThickness)
clip.scroll(to: origin)
scrollView.reflectScrolledClipView(clip)
}
override func resetCursorRects() {
for target in foldTargets {
addCursorRect(target.rect, cursor: .pointingHand)
}
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
for area in trackingAreas where area.owner === self {
removeTrackingArea(area)
}
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], owner: self))
}
override func mouseEntered(with event: NSEvent) {
showsOpenFoldControls = true
}
override func mouseExited(with event: NSEvent) {
showsOpenFoldControls = false
}
override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let hit = foldTargets.first(where: { $0.rect.contains(point) }) else { return }
onToggleFold?(hit.line)
}
override func drawHashMarksAndLabels(in rect: NSRect) {
NSColor.textBackgroundColor.setFill()
rect.fill()
if let band = CurrentLineBand.rect(in: textView, font: sourceFont) {
let top = convert(NSPoint(x: 0, y: band.minY), from: textView).y
let x = Self.leadingInset - Self.bandInset
CurrentLineBand.color.setFill()
NSBezierPath(roundedRect: NSRect(x: x, y: top, width: bounds.width - x + 8, height: band.height), xRadius: 4, yRadius: 4).fill()
}
let foldable = textView.foldableStartLines
let folded = textView.foldedStartLines
let currentLine = caretLine
var openChevrons: [FoldChevron] = []
var targets: [(rect: NSRect, line: Int)] = []
for row in visibleRows() {
drawNumber(row.line + 1, baseline: row.baseline, isCurrent: row.line == currentLine)
guard foldable.contains(row.line) else { continue }
let chevron = FoldChevron(center: NSPoint(x: chevronCenterX, y: row.top + row.height / 2 + Self.chevronDrop), advance: advance)
if folded.contains(row.line) {
chevron.draw(folded: true)
} else {
openChevrons.append(chevron)
}
let cellMinX = chevronColumnMinX - Self.numberToChevron * advance / 2
targets.append((NSRect(x: cellMinX, y: row.top, width: bounds.width - cellMinX, height: row.height), row.line))
}
openFoldControls.chevrons = openChevrons
if !targets.elementsEqual(foldTargets, by: { $0.rect == $1.rect && $0.line == $1.line }) {
foldTargets = targets
window?.invalidateCursorRects(for: self)
}
}
private struct Row {
let line: Int
let top: CGFloat
let height: CGFloat
let baseline: CGFloat
}
private func visibleRows() -> [Row] {
guard let layoutManager = textView.layoutManager as? FoldingLayoutManager else { return [] }
let text = textView.string as NSString
let origin = textView.textContainerOrigin
var rows: [Row] = []
var line = 0
var index = 0
let baselineOffset = layoutManager.defaultBaselineOffset(for: sourceFont)
var previousTop: CGFloat?
while true {
let fragment: NSRect
if index < text.length {
fragment = layoutManager.lineFragmentRect(forGlyphAt: layoutManager.glyphIndexForCharacter(at: index), effectiveRange: nil)
} else if !layoutManager.extraLineFragmentRect.isEmpty {
fragment = layoutManager.extraLineFragmentRect
} else {
break
}
let top = convert(NSPoint(x: 0, y: fragment.minY + origin.y), from: textView).y
let isShown = !layoutManager.isHidden(index) || index == 0
if isShown, top != previousTop, top + fragment.height >= 0, top <= bounds.height {
rows.append(Row(line: line, top: top, height: fragment.height, baseline: top + baselineOffset))
}
if isShown {
previousTop = top
}
guard index < text.length else { break }
index = NSMaxRange(text.lineRange(for: NSRange(location: index, length: 0)))
line += 1
if index == text.length, text.character(at: text.length - 1) != 0x0A { break }
}
return rows
}
private func drawNumber(_ number: Int, baseline: CGFloat, isCurrent: Bool) {
let label = NSAttributedString(string: String(number), attributes: [
.font: sourceFont,
.foregroundColor: isCurrent ? NSColor.labelColor : NSColor.tertiaryLabelColor,
])
let width = label.size().width
label.draw(at: NSPoint(x: Self.leadingInset + numbersWidth - width, y: baseline - sourceFont.ascender))
}
private var chevronColumnMinX: CGFloat {
Self.leadingInset + numbersWidth + Self.numberToChevron * advance
}
private var chevronCenterX: CGFloat {
chevronColumnMinX + Self.chevronWidth * advance / 2
}
fileprivate struct FoldChevron: Equatable {
let center: NSPoint
let advance: CGFloat
static let color = NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(white: 0.77, alpha: 1) : NSColor(white: 0.38, alpha: 1)
}
func draw(folded: Bool) {
let stroke = CodeGutterView.chevronStroke * advance
let halfSpan = (CodeGutterView.chevronWidth * advance - stroke) / 2
let halfDepth = (CodeGutterView.chevronHeight * advance - stroke) / 2
let path = NSBezierPath()
if folded {
path.move(to: NSPoint(x: center.x - halfDepth, y: center.y - halfSpan))
path.line(to: NSPoint(x: center.x + halfDepth, y: center.y))
path.line(to: NSPoint(x: center.x - halfDepth, y: center.y + halfSpan))
} else {
path.move(to: NSPoint(x: center.x - halfSpan, y: center.y - halfDepth))
path.line(to: NSPoint(x: center.x, y: center.y + halfDepth))
path.line(to: NSPoint(x: center.x + halfSpan, y: center.y - halfDepth))
}
path.lineWidth = stroke
path.lineJoinStyle = .miter
path.lineCapStyle = .butt
Self.color.setStroke()
path.stroke()
}
}
private var caretLine: Int? {
let selection = textView.selectedRange()
guard selection.length == 0 else { return nil }
let prefix = (textView.string as NSString).substring(to: min(selection.location, (textView.string as NSString).length))
return prefix.utf16.reduce(0) { $1 == 0x0A ? $0 + 1 : $0 }
}
private var lineCount: Int {
textView.string.utf16.reduce(1) { $1 == 0x0A ? $0 + 1 : $0 }
}
private var numbersWidth: CGFloat {
CGFloat(digitCount) * advance
}
private var advance: CGFloat {
("0" as NSString).size(withAttributes: [.font: sourceFont]).width
}
private var sourceFont: NSFont {
textView.sourceFont
}
}
private final class OpenFoldControlsView: NSView {
var chevrons: [CodeGutterView.FoldChevron] = [] {
didSet {
if chevrons != oldValue { needsDisplay = true }
}
}
override var isFlipped: Bool { true }
override func draw(_ dirtyRect: NSRect) {
for chevron in chevrons {
chevron.draw(folded: false)
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
#endif