180 lines
7.5 KiB
Swift
180 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
|