Sapling/Sources/SaplingEditor/MarkdownPresentationSnapshot.swift

179 lines
7.5 KiB
Swift

import Foundation
#if os(macOS)
import AppKit
struct MarkdownPresentationSnapshot: Hashable {
var lines: [String]
var signature: String {
lines.joined(separator: "\n")
}
static func make(
source: String,
activeLineIndex: Int,
containerWidth: CGFloat = 420,
usesRenderedControls: Bool = true
) -> MarkdownPresentationSnapshot {
let storage = NSTextStorage(string: source)
let lineIndex = DocumentLineIndex(source: source)
MarkdownTextStyler.apply(
to: storage,
lineIndex: lineIndex,
invalidationPlan: EditorDirtyLineInvalidator.plan(
previousText: nil,
currentLineIndex: lineIndex,
edit: nil,
previousActiveLineIndex: nil,
currentActiveLineIndex: activeLineIndex
),
activeLineIndex: activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor,
usesRenderedControls: usesRenderedControls
)
return make(from: storage, lineIndex: lineIndex, containerWidth: containerWidth)
}
static func make(
from storage: NSTextStorage,
lineIndex: DocumentLineIndex,
containerWidth: CGFloat = 420
) -> MarkdownPresentationSnapshot {
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: containerWidth, height: CGFloat.greatestFiniteMagnitude))
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
storage.addLayoutManager(layoutManager)
defer {
storage.removeLayoutManager(layoutManager)
}
layoutManager.ensureLayout(for: textContainer)
var lines: [String] = []
for boundary in lineIndex.boundaries {
let contentRange = boundary.contentRange
let paragraphRange = NSRange(
location: boundary.contentRange.location,
length: min(boundary.nextLineLocation, storage.length) - boundary.contentRange.location
)
let glyphRange = layoutManager.glyphRange(forCharacterRange: paragraphRange, actualCharacterRange: nil)
let fragment = glyphRange.length > 0
? layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
: .zero
let paragraphStyle = storage.attribute(.paragraphStyle, at: min(paragraphRange.location, max(0, storage.length - 1)), effectiveRange: nil) as? NSParagraphStyle
let font = contentRange.length > 0
? storage.attribute(.font, at: contentRange.location, effectiveRange: nil) as? NSFont
: nil
lines.append([
"line=\(boundary.index)",
"range=\(paragraphRange.location):\(paragraphRange.length)",
"fragment=\(rounded(fragment.minX)),\(rounded(fragment.minY)),\(rounded(fragment.width)),\(rounded(fragment.height))",
"font=\(fontDescription(font))",
"paragraph=\(paragraphDescription(paragraphStyle))",
"runs=\(attributeRuns(in: storage, range: paragraphRange).joined(separator: ","))"
].joined(separator: "|"))
}
return MarkdownPresentationSnapshot(lines: lines)
}
static func contentOrigin(
source: String,
text: String,
activeLineIndex: Int,
containerWidth: CGFloat = 420,
usesRenderedControls: Bool = true
) -> CGPoint? {
let storage = NSTextStorage(string: source)
let lineIndex = DocumentLineIndex(source: source)
MarkdownTextStyler.apply(
to: storage,
lineIndex: lineIndex,
invalidationPlan: EditorDirtyLineInvalidator.plan(
previousText: nil,
currentLineIndex: lineIndex,
edit: nil,
previousActiveLineIndex: nil,
currentActiveLineIndex: activeLineIndex
),
activeLineIndex: activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor,
usesRenderedControls: usesRenderedControls
)
let textRange = (source as NSString).range(of: text)
guard textRange.location != NSNotFound else { return nil }
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: containerWidth, height: CGFloat.greatestFiniteMagnitude))
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
storage.addLayoutManager(layoutManager)
defer {
storage.removeLayoutManager(layoutManager)
}
layoutManager.ensureLayout(for: textContainer)
let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: textRange.location, length: 1), actualCharacterRange: nil)
guard glyphRange.length > 0 else { return nil }
let lineFragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
return CGPoint(x: lineFragment.minX + glyphLocation.x, y: lineFragment.minY + glyphLocation.y)
}
private static func attributeRuns(in storage: NSTextStorage, range: NSRange) -> [String] {
guard range.length > 0 else { return [] }
var runs: [String] = []
storage.enumerateAttributes(in: range) { attributes, effectiveRange, _ in
let font = attributes[.font] as? NSFont
let foreground = attributes[.foregroundColor] as? NSColor
let background = attributes[.backgroundColor] as? NSColor
let paragraph = attributes[.paragraphStyle] as? NSParagraphStyle
let hidden = foreground?.alphaComponent == 0 && (font?.pointSize ?? 0) < 1
runs.append([
"\(effectiveRange.location):\(effectiveRange.length)",
fontDescription(font),
"fg=\(rounded(foreground?.alphaComponent ?? 1))",
"bg=\(rounded(background?.alphaComponent ?? 0))",
"hidden=\(hidden)",
paragraphDescription(paragraph)
].joined(separator: "/"))
}
return runs
}
private static func fontDescription(_ font: NSFont?) -> String {
guard let font else { return "nil" }
let isMono = font.fontDescriptor.symbolicTraits.contains(.monoSpace)
return "\(isMono ? "mono" : "system"):\(rounded(font.pointSize))"
}
private static func paragraphDescription(_ paragraph: NSParagraphStyle?) -> String {
guard let paragraph else { return "nil" }
return [
"ls=\(rounded(paragraph.lineSpacing))",
"psb=\(rounded(paragraph.paragraphSpacingBefore))",
"ps=\(rounded(paragraph.paragraphSpacing))",
"first=\(rounded(paragraph.firstLineHeadIndent))",
"head=\(rounded(paragraph.headIndent))"
].joined(separator: ":")
}
private static func rounded(_ value: CGFloat) -> String {
String(format: "%.3f", Double(value))
}
}
#endif