652 lines
26 KiB
Swift
652 lines
26 KiB
Swift
import Foundation
|
|
import SaplingCore
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
public enum EditorBenchmarkCategory: String, Hashable, Sendable {
|
|
case documentLoad
|
|
case editorUpdate
|
|
case rendering
|
|
case layout
|
|
case interaction
|
|
}
|
|
|
|
public struct EditorBenchmarkMeasurement: Hashable, Sendable {
|
|
public var name: String
|
|
public var category: EditorBenchmarkCategory
|
|
public var durationMilliseconds: Double
|
|
public var notes: String
|
|
|
|
public init(
|
|
name: String,
|
|
category: EditorBenchmarkCategory,
|
|
durationMilliseconds: Double,
|
|
notes: String = ""
|
|
) {
|
|
self.name = name
|
|
self.category = category
|
|
self.durationMilliseconds = durationMilliseconds
|
|
self.notes = notes
|
|
}
|
|
}
|
|
|
|
public struct EditorBenchmarkDocumentProfile: Hashable, Sendable {
|
|
public var fileName: String
|
|
public var byteCount: Int
|
|
public var characterCount: Int
|
|
public var utf16Count: Int
|
|
public var lineCount: Int
|
|
public var headingCount: Int
|
|
public var unorderedListItemCount: Int
|
|
public var orderedListItemCount: Int
|
|
public var blockquoteCount: Int
|
|
public var fencedCodeFenceCount: Int
|
|
public var inlineCodeLineCount: Int
|
|
public var boldLineCount: Int
|
|
public var italicLineCount: Int
|
|
public var inlineLinkCount: Int
|
|
public var referenceLinkLikeCount: Int
|
|
public var imageCount: Int
|
|
public var maxLineUTF16Length: Int
|
|
public var averageLineUTF16Length: Double
|
|
|
|
public var markdownStructureSummary: String {
|
|
[
|
|
"\(headingCount) ATX headings",
|
|
"\(unorderedListItemCount) unordered list items",
|
|
"\(orderedListItemCount) ordered list items",
|
|
"\(blockquoteCount) blockquotes",
|
|
"\(fencedCodeFenceCount) fenced code fences",
|
|
"\(inlineCodeLineCount) lines with inline code",
|
|
"\(boldLineCount) lines with bold markers",
|
|
"\(italicLineCount) lines with italic markers",
|
|
"\(inlineLinkCount) inline links",
|
|
"\(referenceLinkLikeCount) reference-style link markers",
|
|
"\(imageCount) images"
|
|
].joined(separator: ", ")
|
|
}
|
|
}
|
|
|
|
public struct EditorBenchmarkResult: Sendable {
|
|
public var document: EditorBenchmarkDocumentProfile
|
|
public var measurements: [EditorBenchmarkMeasurement]
|
|
public var renderedModeTrace: EditorRenderedModeTrace
|
|
|
|
public var measuredTotalMilliseconds: Double {
|
|
measurements.reduce(0) { $0 + $1.durationMilliseconds }
|
|
}
|
|
|
|
public var hottestMeasurements: [EditorBenchmarkMeasurement] {
|
|
measurements.sorted { $0.durationMilliseconds > $1.durationMilliseconds }
|
|
}
|
|
}
|
|
|
|
public struct EditorRenderedModeTrace: Hashable, Sendable {
|
|
public var totalLineCount: Int
|
|
public var sourceLineCount: Int
|
|
public var renderedLineCount: Int
|
|
public var headingPlanCount: Int
|
|
public var paragraphPlanCount: Int
|
|
public var inlineSpanCount: Int
|
|
public var markdownDelimiterSpanCount: Int
|
|
public var unsupportedReferenceLinkLikeCount: Int
|
|
|
|
public var renderedModeWasScheduled: Bool {
|
|
renderedLineCount > 0
|
|
}
|
|
}
|
|
|
|
public enum EditorBenchmarkProfiler {
|
|
public static func profileDocument(at url: URL) throws -> EditorBenchmarkResult {
|
|
var measurements: [EditorBenchmarkMeasurement] = []
|
|
|
|
let readResult = try measure {
|
|
try String(contentsOf: url, encoding: .utf8)
|
|
}
|
|
let source = readResult.value
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "file_read",
|
|
category: .documentLoad,
|
|
durationMilliseconds: readResult.durationMilliseconds,
|
|
notes: "String(contentsOf:encoding:)"
|
|
))
|
|
|
|
let profile = documentProfile(fileName: url.lastPathComponent, source: source)
|
|
let midpoint = source.utf16.count / 2
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
let activeLineIndex = lineIndex.lineIndex(containing: midpoint)
|
|
|
|
let documentResult = measure {
|
|
MarkdownDocument(url: url, title: url.deletingPathExtension().lastPathComponent, content: source)
|
|
}
|
|
let markdownDocument = documentResult.value
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "document_model_init",
|
|
category: .documentLoad,
|
|
durationMilliseconds: documentResult.durationMilliseconds,
|
|
notes: "MarkdownDocument construction"
|
|
))
|
|
|
|
let stateResult = measure {
|
|
EditorState(
|
|
document: EditorDocument(markdownDocument: markdownDocument),
|
|
activeLineIndex: activeLineIndex
|
|
)
|
|
}
|
|
var state = stateResult.value
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "document_parse_editor_state_init",
|
|
category: .documentLoad,
|
|
durationMilliseconds: stateResult.durationMilliseconds,
|
|
notes: "Builds EditorState.lines for the whole document"
|
|
))
|
|
|
|
let lineGenerationResult = measure {
|
|
EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex)
|
|
}
|
|
let lines = lineGenerationResult.value
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "line_model_generation",
|
|
category: .editorUpdate,
|
|
durationMilliseconds: lineGenerationResult.durationMilliseconds,
|
|
notes: "EditorActiveLineTracker.lines over full source"
|
|
))
|
|
|
|
let renderPlanResult = measure {
|
|
let renderer = HybridMarkdownLineRenderer()
|
|
return lines.map(renderer.renderPlan(for:))
|
|
}
|
|
let renderPlans = renderPlanResult.value
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "render_plan_generation_all_lines",
|
|
category: .rendering,
|
|
durationMilliseconds: renderPlanResult.durationMilliseconds,
|
|
notes: "HybridMarkdownLineRenderer.renderPlan for every line"
|
|
))
|
|
|
|
let renderedModeTrace = renderedTrace(
|
|
lines: lines,
|
|
renderPlans: renderPlans,
|
|
referenceLinkLikeCount: profile.referenceLinkLikeCount
|
|
)
|
|
|
|
let typingEdit = DocumentLineIndexEdit(
|
|
range: NSRange(location: midpoint, length: 0),
|
|
replacement: "x"
|
|
)
|
|
let changedSource = sourceByInsertingProbeText(in: source, at: midpoint)
|
|
var changedLineIndex = lineIndex
|
|
changedLineIndex.replace(typingEdit)
|
|
let changedActiveLineIndex = changedLineIndex.lineIndex(containing: midpoint + 1)
|
|
|
|
let activeLineResult = measure {
|
|
lineIndex.lineIndex(containing: midpoint)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "active_line_lookup",
|
|
category: .editorUpdate,
|
|
durationMilliseconds: activeLineResult.durationMilliseconds,
|
|
notes: "Line lookup for midpoint cursor location"
|
|
))
|
|
|
|
let selectionUpdateResult = measure {
|
|
state.updateSelection(EditorSelection(location: midpoint, length: 0))
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "selection_update",
|
|
category: .editorUpdate,
|
|
durationMilliseconds: selectionUpdateResult.durationMilliseconds,
|
|
notes: "EditorState.updateSelection rebuilds line model"
|
|
))
|
|
|
|
let dirtyClickResult = measure {
|
|
EditorDirtyLineInvalidator.plan(
|
|
previousText: source,
|
|
currentLineIndex: lineIndex,
|
|
edit: nil,
|
|
previousActiveLineIndex: activeLineIndex,
|
|
currentActiveLineIndex: changedActiveLineIndex
|
|
)
|
|
}
|
|
let dirtyClickPlan = dirtyClickResult.value
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "dirty_line_invalidation_click",
|
|
category: .editorUpdate,
|
|
durationMilliseconds: dirtyClickResult.durationMilliseconds,
|
|
notes: "\(dirtyClickPlan.dirtyLineCount) dirty lines"
|
|
))
|
|
|
|
let sourceUpdateResult = measure {
|
|
var updatedState = state
|
|
updatedState.updateSource(
|
|
typingEdit,
|
|
selection: EditorSelection(location: midpoint + typingEdit.replacementUTF16Length, length: 0)
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "typing_state_update",
|
|
category: .interaction,
|
|
durationMilliseconds: sourceUpdateResult.durationMilliseconds,
|
|
notes: "EditorState.updateSource after one-character insertion"
|
|
))
|
|
|
|
let dirtyTypingResult = measure {
|
|
EditorDirtyLineInvalidator.plan(
|
|
previousText: source,
|
|
currentLineIndex: changedLineIndex,
|
|
edit: typingEdit,
|
|
previousActiveLineIndex: activeLineIndex,
|
|
currentActiveLineIndex: changedActiveLineIndex
|
|
)
|
|
}
|
|
let dirtyTypingPlan = dirtyTypingResult.value
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "dirty_line_invalidation_typing",
|
|
category: .editorUpdate,
|
|
durationMilliseconds: dirtyTypingResult.durationMilliseconds,
|
|
notes: "\(dirtyTypingPlan.dirtyLineCount) dirty lines"
|
|
))
|
|
|
|
#if os(macOS)
|
|
measurements.append(contentsOf: profileTextKit(
|
|
source: source,
|
|
changedSource: changedSource,
|
|
lineIndex: lineIndex,
|
|
changedLineIndex: changedLineIndex,
|
|
typingEdit: typingEdit,
|
|
activeLineIndex: activeLineIndex,
|
|
changedActiveLineIndex: changedActiveLineIndex,
|
|
dirtyClickPlan: dirtyClickPlan,
|
|
dirtyTypingPlan: dirtyTypingPlan
|
|
))
|
|
#endif
|
|
|
|
return EditorBenchmarkResult(
|
|
document: profile,
|
|
measurements: measurements,
|
|
renderedModeTrace: renderedModeTrace
|
|
)
|
|
}
|
|
|
|
public static func documentProfile(fileName: String, source: String) -> EditorBenchmarkDocumentProfile {
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
let nsSource = source as NSString
|
|
let lineSources = lineIndex.boundaries.map { nsSource.substring(with: $0.contentRange) }
|
|
let lineLengths = lineIndex.boundaries.map(\.contentRange.length)
|
|
let lineCount = lineIndex.boundaries.count
|
|
let maxLineLength = lineLengths.max() ?? 0
|
|
let totalLineLength = lineLengths.reduce(0, +)
|
|
|
|
return EditorBenchmarkDocumentProfile(
|
|
fileName: fileName,
|
|
byteCount: source.lengthOfBytes(using: .utf8),
|
|
characterCount: source.count,
|
|
utf16Count: source.utf16.count,
|
|
lineCount: lineCount,
|
|
headingCount: countMatches("(?m)^#{1,6}\\s", in: source),
|
|
unorderedListItemCount: countMatches("(?m)^[-*+]\\s", in: source),
|
|
orderedListItemCount: countMatches("(?m)^\\d+\\.\\s", in: source),
|
|
blockquoteCount: countMatches("(?m)^>\\s", in: source),
|
|
fencedCodeFenceCount: countMatches("(?m)^```", in: source),
|
|
inlineCodeLineCount: countMatchingLines("`[^`\\n]+`", in: lineSources),
|
|
boldLineCount: countMatchingLines("\\*\\*[^*\\n]+\\*\\*", in: lineSources),
|
|
italicLineCount: countMatchingLines("(?<!\\*)\\*[^*\\n]+\\*(?!\\*)", in: lineSources),
|
|
inlineLinkCount: countMatches("(?<!!)\\[[^\\]]+\\]\\([^\\)]+\\)", in: source),
|
|
referenceLinkLikeCount: countMatches("(?<!!)\\[[A-Za-z0-9_-]+\\]", in: source),
|
|
imageCount: countMatches("!\\[", in: source),
|
|
maxLineUTF16Length: maxLineLength,
|
|
averageLineUTF16Length: lineCount == 0 ? 0 : Double(totalLineLength) / Double(lineCount)
|
|
)
|
|
}
|
|
|
|
private static func renderedTrace(
|
|
lines: [EditorLine],
|
|
renderPlans: [HybridMarkdownLineRenderPlan],
|
|
referenceLinkLikeCount: Int
|
|
) -> EditorRenderedModeTrace {
|
|
let headingCount = renderPlans.filter {
|
|
if case .heading = $0.kind {
|
|
return true
|
|
}
|
|
return false
|
|
}.count
|
|
let paragraphCount = renderPlans.count - headingCount
|
|
let spans = renderPlans.flatMap(\.spans)
|
|
|
|
return EditorRenderedModeTrace(
|
|
totalLineCount: lines.count,
|
|
sourceLineCount: lines.filter { $0.mode == .source }.count,
|
|
renderedLineCount: lines.filter { $0.mode == .rendered }.count,
|
|
headingPlanCount: headingCount,
|
|
paragraphPlanCount: paragraphCount,
|
|
inlineSpanCount: spans.count,
|
|
markdownDelimiterSpanCount: spans.filter { $0.kind == .markdownDelimiter }.count,
|
|
unsupportedReferenceLinkLikeCount: referenceLinkLikeCount
|
|
)
|
|
}
|
|
|
|
private static func sourceByInsertingProbeText(in source: String, at utf16Location: Int) -> String {
|
|
let nsSource = source as NSString
|
|
let location = max(0, min(utf16Location, nsSource.length))
|
|
return nsSource.replacingCharacters(in: NSRange(location: location, length: 0), with: "x")
|
|
}
|
|
|
|
private static func countMatches(_ pattern: String, in source: String) -> Int {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
|
|
return regex.numberOfMatches(in: source, range: NSRange(location: 0, length: source.utf16.count))
|
|
}
|
|
|
|
private static func countMatchingLines(_ pattern: String, in lines: [String]) -> Int {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
|
|
return lines.reduce(0) { count, line in
|
|
let range = NSRange(location: 0, length: line.utf16.count)
|
|
return count + (regex.firstMatch(in: line, range: range) == nil ? 0 : 1)
|
|
}
|
|
}
|
|
|
|
private static func measure<T>(_ operation: () throws -> T) rethrows -> (value: T, durationMilliseconds: Double) {
|
|
let start = DispatchTime.now().uptimeNanoseconds
|
|
let value = try operation()
|
|
let end = DispatchTime.now().uptimeNanoseconds
|
|
return (value, Double(end - start) / 1_000_000)
|
|
}
|
|
|
|
#if os(macOS)
|
|
private static func profileTextKit(
|
|
source: String,
|
|
changedSource: String,
|
|
lineIndex: DocumentLineIndex,
|
|
changedLineIndex: DocumentLineIndex,
|
|
typingEdit: DocumentLineIndexEdit,
|
|
activeLineIndex: Int,
|
|
changedActiveLineIndex: Int,
|
|
dirtyClickPlan: EditorDirtyLineInvalidationPlan,
|
|
dirtyTypingPlan: EditorDirtyLineInvalidationPlan
|
|
) -> [EditorBenchmarkMeasurement] {
|
|
var measurements: [EditorBenchmarkMeasurement] = []
|
|
let textStorage = NSTextStorage()
|
|
let layoutManager = NSLayoutManager()
|
|
let textContainer = NSTextContainer(size: NSSize(width: 760, height: CGFloat.greatestFiniteMagnitude))
|
|
textContainer.widthTracksTextView = false
|
|
textContainer.heightTracksTextView = false
|
|
layoutManager.addTextContainer(textContainer)
|
|
textStorage.addLayoutManager(layoutManager)
|
|
|
|
let storageUpdateResult = measure {
|
|
textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: source)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "text_storage_initial_update",
|
|
category: .layout,
|
|
durationMilliseconds: storageUpdateResult.durationMilliseconds,
|
|
notes: "NSTextStorage.replaceCharacters full source"
|
|
))
|
|
|
|
let fullRenderPlan = EditorDirtyLineInvalidator.plan(
|
|
previousText: nil,
|
|
currentLineIndex: lineIndex,
|
|
edit: nil,
|
|
previousActiveLineIndex: nil,
|
|
currentActiveLineIndex: activeLineIndex
|
|
)
|
|
let attributedStringResult = measure {
|
|
MarkdownTextStyler.apply(
|
|
to: textStorage,
|
|
lineIndex: lineIndex,
|
|
invalidationPlan: fullRenderPlan,
|
|
activeLineIndex: activeLineIndex,
|
|
backgroundColor: .textBackgroundColor,
|
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
|
textColor: .labelColor,
|
|
secondaryTextColor: .secondaryLabelColor,
|
|
accentColor: .controlAccentColor
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "attributed_string_generation_initial",
|
|
category: .rendering,
|
|
durationMilliseconds: attributedStringResult.durationMilliseconds,
|
|
notes: "\(attributedStringResult.value.styledLineCount) styled lines"
|
|
))
|
|
|
|
let coldViewportStack = makeTextKitStack(attributedString: textStorage)
|
|
let coldViewportResult = measure {
|
|
coldViewportStack.layoutManager.glyphRange(
|
|
forBoundingRect: NSRect(x: 0, y: 50_000, width: 760, height: 900),
|
|
in: coldViewportStack.textContainer
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "cold_viewport_glyph_range_middle",
|
|
category: .layout,
|
|
durationMilliseconds: coldViewportResult.durationMilliseconds,
|
|
notes: "Visible glyph range before explicit full-document layout"
|
|
))
|
|
|
|
let coldLineFragmentStack = makeTextKitStack(attributedString: textStorage)
|
|
let coldLineFragmentResult = measure {
|
|
lineFragmentRect(atCharacterLocation: coldLineFragmentStack.textStorage.length / 2, in: coldLineFragmentStack)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "cold_line_fragment_calculation_midpoint",
|
|
category: .layout,
|
|
durationMilliseconds: coldLineFragmentResult.durationMilliseconds,
|
|
notes: "lineFragmentRect near midpoint before explicit full-document layout"
|
|
))
|
|
|
|
let layoutInvalidationResult = measure {
|
|
var actualRange = NSRange(location: 0, length: 0)
|
|
layoutManager.invalidateLayout(
|
|
forCharacterRange: NSRange(location: 0, length: textStorage.length),
|
|
actualCharacterRange: &actualRange
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "layout_invalidation_full_document",
|
|
category: .layout,
|
|
durationMilliseconds: layoutInvalidationResult.durationMilliseconds,
|
|
notes: "NSLayoutManager.invalidateLayout full character range"
|
|
))
|
|
|
|
let glyphGenerationResult = measure {
|
|
layoutManager.ensureGlyphs(forCharacterRange: NSRange(location: 0, length: textStorage.length))
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "glyph_generation_full_document",
|
|
category: .layout,
|
|
durationMilliseconds: glyphGenerationResult.durationMilliseconds,
|
|
notes: "NSLayoutManager.ensureGlyphs full character range"
|
|
))
|
|
|
|
let layoutGenerationResult = measure {
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "layout_generation_full_document",
|
|
category: .layout,
|
|
durationMilliseconds: layoutGenerationResult.durationMilliseconds,
|
|
notes: "NSLayoutManager.ensureLayout for text container"
|
|
))
|
|
|
|
let cachedLineFragmentResult = measure {
|
|
lineFragmentRect(atCharacterLocation: textStorage.length / 2, in: (
|
|
textStorage: textStorage,
|
|
layoutManager: layoutManager,
|
|
textContainer: textContainer
|
|
))
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "cached_line_fragment_calculation_midpoint",
|
|
category: .layout,
|
|
durationMilliseconds: cachedLineFragmentResult.durationMilliseconds,
|
|
notes: "lineFragmentRect near midpoint after full layout"
|
|
))
|
|
|
|
let textContainerUsedRectResult = measure {
|
|
layoutManager.usedRect(for: textContainer)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "text_container_used_rect_after_full_layout",
|
|
category: .layout,
|
|
durationMilliseconds: textContainerUsedRectResult.durationMilliseconds,
|
|
notes: "NSTextContainer usedRect after full layout"
|
|
))
|
|
|
|
let firstViewportResult = measure {
|
|
layoutManager.glyphRange(
|
|
forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900),
|
|
in: textContainer
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "first_viewport_glyph_range",
|
|
category: .layout,
|
|
durationMilliseconds: firstViewportResult.durationMilliseconds,
|
|
notes: "Visible glyph range at document top"
|
|
))
|
|
|
|
let clickRenderResult = measure {
|
|
if dirtyClickPlan.requiresStyling {
|
|
return MarkdownTextStyler.apply(
|
|
to: textStorage,
|
|
lineIndex: lineIndex,
|
|
invalidationPlan: dirtyClickPlan,
|
|
activeLineIndex: changedActiveLineIndex,
|
|
backgroundColor: .textBackgroundColor,
|
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
|
textColor: .labelColor,
|
|
secondaryTextColor: .secondaryLabelColor,
|
|
accentColor: .controlAccentColor
|
|
)
|
|
}
|
|
return MarkdownTextStylingResult.empty
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "render_update_click_dirty",
|
|
category: .interaction,
|
|
durationMilliseconds: clickRenderResult.durationMilliseconds,
|
|
notes: dirtyClickPlan.requiresStyling
|
|
? "\(clickRenderResult.value.styledLineCount) styled lines"
|
|
: "skipped; dirty plan did not require styling"
|
|
))
|
|
|
|
let typingStorageResult = measure {
|
|
let midpoint = textStorage.length / 2
|
|
textStorage.replaceCharacters(in: NSRange(location: midpoint, length: 0), with: "x")
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "text_storage_typing_insert",
|
|
category: .interaction,
|
|
durationMilliseconds: typingStorageResult.durationMilliseconds,
|
|
notes: "NSTextStorage single-character insertion"
|
|
))
|
|
|
|
let typingRenderResult = measure {
|
|
MarkdownTextStyler.apply(
|
|
to: textStorage,
|
|
lineIndex: changedLineIndex,
|
|
invalidationPlan: dirtyTypingPlan,
|
|
activeLineIndex: changedActiveLineIndex,
|
|
backgroundColor: .textBackgroundColor,
|
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
|
textColor: .labelColor,
|
|
secondaryTextColor: .secondaryLabelColor,
|
|
accentColor: .controlAccentColor
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "render_update_typing_dirty",
|
|
category: .interaction,
|
|
durationMilliseconds: typingRenderResult.durationMilliseconds,
|
|
notes: "\(typingRenderResult.value.styledLineCount) styled lines"
|
|
))
|
|
|
|
let layoutAfterTypingResult = measure {
|
|
layoutManager.ensureLayout(forCharacterRange: NSRange(location: textStorage.length / 2, length: 1))
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "layout_after_typing",
|
|
category: .interaction,
|
|
durationMilliseconds: layoutAfterTypingResult.durationMilliseconds,
|
|
notes: "NSLayoutManager.ensureLayout for inserted character"
|
|
))
|
|
|
|
let scrollTopResult = measure {
|
|
layoutManager.glyphRange(
|
|
forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900),
|
|
in: textContainer
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "scroll_latency_top_viewport",
|
|
category: .interaction,
|
|
durationMilliseconds: scrollTopResult.durationMilliseconds,
|
|
notes: "glyphRange for top viewport"
|
|
))
|
|
|
|
let scrollMiddleResult = measure {
|
|
layoutManager.glyphRange(
|
|
forBoundingRect: NSRect(x: 0, y: 50_000, width: 760, height: 900),
|
|
in: textContainer
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "scroll_latency_middle_viewport",
|
|
category: .interaction,
|
|
durationMilliseconds: scrollMiddleResult.durationMilliseconds,
|
|
notes: "glyphRange for estimated middle viewport"
|
|
))
|
|
|
|
let scrollDeepResult = measure {
|
|
layoutManager.glyphRange(
|
|
forBoundingRect: NSRect(x: 0, y: 500_000, width: 760, height: 900),
|
|
in: textContainer
|
|
)
|
|
}
|
|
measurements.append(EditorBenchmarkMeasurement(
|
|
name: "scroll_latency_deep_viewport",
|
|
category: .interaction,
|
|
durationMilliseconds: scrollDeepResult.durationMilliseconds,
|
|
notes: "glyphRange for estimated deep viewport"
|
|
))
|
|
|
|
_ = changedSource
|
|
_ = typingEdit
|
|
return measurements
|
|
}
|
|
|
|
private static func makeTextKitStack(attributedString: NSAttributedString) -> (
|
|
textStorage: NSTextStorage,
|
|
layoutManager: NSLayoutManager,
|
|
textContainer: NSTextContainer
|
|
) {
|
|
let textStorage = NSTextStorage(attributedString: attributedString)
|
|
let layoutManager = NSLayoutManager()
|
|
let textContainer = NSTextContainer(size: NSSize(width: 760, height: CGFloat.greatestFiniteMagnitude))
|
|
textContainer.widthTracksTextView = false
|
|
textContainer.heightTracksTextView = false
|
|
layoutManager.addTextContainer(textContainer)
|
|
textStorage.addLayoutManager(layoutManager)
|
|
return (textStorage, layoutManager, textContainer)
|
|
}
|
|
|
|
@discardableResult
|
|
private static func lineFragmentRect(
|
|
atCharacterLocation characterLocation: Int,
|
|
in stack: (
|
|
textStorage: NSTextStorage,
|
|
layoutManager: NSLayoutManager,
|
|
textContainer: NSTextContainer
|
|
)
|
|
) -> NSRect {
|
|
guard stack.textStorage.length > 0 else { return .zero }
|
|
|
|
let characterLocation = max(0, min(characterLocation, stack.textStorage.length - 1))
|
|
let glyphIndex = stack.layoutManager.glyphIndexForCharacter(at: characterLocation)
|
|
var effectiveRange = NSRange(location: 0, length: 0)
|
|
return stack.layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveRange)
|
|
}
|
|
#endif
|
|
}
|