feat(editor): validate hybrid active line rendering
This commit is contained in:
parent
b2ae51d7a8
commit
c458fb1529
6 changed files with 572 additions and 157 deletions
64
Sources/SaplingEditor/EditorActiveLineTracker.swift
Normal file
64
Sources/SaplingEditor/EditorActiveLineTracker.swift
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import Foundation
|
||||
|
||||
public enum EditorActiveLineTracker {
|
||||
public static func lines(from source: String, activeLineIndex: Int) -> [EditorLine] {
|
||||
var lines: [EditorLine] = []
|
||||
var lineStart = source.startIndex
|
||||
var utf16Location = 0
|
||||
var index = 0
|
||||
|
||||
while lineStart < source.endIndex {
|
||||
let lineEnd = source[lineStart...].firstIndex(of: "\n") ?? source.endIndex
|
||||
let line = String(source[lineStart..<lineEnd])
|
||||
let length = line.utf16.count
|
||||
lines.append(EditorLine(
|
||||
index: index,
|
||||
source: line,
|
||||
range: NSRange(location: utf16Location, length: length),
|
||||
mode: index == activeLineIndex ? .source : .rendered
|
||||
))
|
||||
|
||||
if lineEnd == source.endIndex {
|
||||
lineStart = lineEnd
|
||||
utf16Location += length
|
||||
} else {
|
||||
lineStart = source.index(after: lineEnd)
|
||||
utf16Location += length + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
if source.isEmpty || source.hasSuffix("\n") {
|
||||
lines.append(EditorLine(
|
||||
index: index,
|
||||
source: "",
|
||||
range: NSRange(location: utf16Location, length: 0),
|
||||
mode: index == activeLineIndex ? .source : .rendered
|
||||
))
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
public static func lineIndex(containing location: Int, in source: String) -> Int {
|
||||
let clampedLocation = max(0, min(location, source.utf16.count))
|
||||
var currentLocation = 0
|
||||
|
||||
for (index, line) in source.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
|
||||
let length = line.utf16.count
|
||||
if clampedLocation <= currentLocation + length {
|
||||
return index
|
||||
}
|
||||
currentLocation += length + 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
public static func clampedSelection(_ selection: EditorSelection, in source: String) -> EditorSelection {
|
||||
let sourceLength = source.utf16.count
|
||||
let location = max(0, min(selection.location, sourceLength))
|
||||
let length = max(0, min(selection.length, sourceLength - location))
|
||||
return EditorSelection(location: location, length: length)
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ public struct EditorState: Hashable, Sendable {
|
|||
self.document = document
|
||||
self.selection = selection
|
||||
self.activeLineIndex = activeLineIndex
|
||||
self.lines = Self.makeLines(
|
||||
self.lines = EditorActiveLineTracker.lines(
|
||||
from: document.source,
|
||||
activeLineIndex: activeLineIndex
|
||||
)
|
||||
|
|
@ -115,73 +115,20 @@ public struct EditorState: Hashable, Sendable {
|
|||
|
||||
public mutating func updateSource(_ source: String) {
|
||||
document.source = source
|
||||
activeLineIndex = Self.lineIndex(containing: selection.location, in: source)
|
||||
lines = Self.makeLines(from: source, activeLineIndex: activeLineIndex)
|
||||
selection = EditorActiveLineTracker.clampedSelection(selection, in: source)
|
||||
activeLineIndex = EditorActiveLineTracker.lineIndex(containing: selection.location, in: source)
|
||||
lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex)
|
||||
}
|
||||
|
||||
public mutating func updateSelection(_ selection: EditorSelection) {
|
||||
self.selection = selection
|
||||
activeLineIndex = Self.lineIndex(containing: selection.location, in: document.source)
|
||||
lines = Self.makeLines(from: document.source, activeLineIndex: activeLineIndex)
|
||||
self.selection = EditorActiveLineTracker.clampedSelection(selection, in: document.source)
|
||||
activeLineIndex = EditorActiveLineTracker.lineIndex(containing: self.selection.location, in: document.source)
|
||||
lines = EditorActiveLineTracker.lines(from: document.source, activeLineIndex: activeLineIndex)
|
||||
}
|
||||
|
||||
public mutating func markSaved() {
|
||||
document.lastSavedSource = document.source
|
||||
}
|
||||
|
||||
private static func makeLines(from source: String, activeLineIndex: Int) -> [EditorLine] {
|
||||
var lines: [EditorLine] = []
|
||||
var lineStart = source.startIndex
|
||||
var utf16Location = 0
|
||||
var index = 0
|
||||
|
||||
while lineStart < source.endIndex {
|
||||
let lineEnd = source[lineStart...].firstIndex(of: "\n") ?? source.endIndex
|
||||
let line = String(source[lineStart..<lineEnd])
|
||||
let length = line.utf16.count
|
||||
lines.append(EditorLine(
|
||||
index: index,
|
||||
source: line,
|
||||
range: NSRange(location: utf16Location, length: length),
|
||||
mode: index == activeLineIndex ? .source : .rendered
|
||||
))
|
||||
|
||||
if lineEnd == source.endIndex {
|
||||
lineStart = lineEnd
|
||||
utf16Location += length
|
||||
} else {
|
||||
lineStart = source.index(after: lineEnd)
|
||||
utf16Location += length + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
if source.isEmpty || source.hasSuffix("\n") {
|
||||
lines.append(EditorLine(
|
||||
index: index,
|
||||
source: "",
|
||||
range: NSRange(location: utf16Location, length: 0),
|
||||
mode: index == activeLineIndex ? .source : .rendered
|
||||
))
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func lineIndex(containing location: Int, in source: String) -> Int {
|
||||
let clampedLocation = max(0, min(location, source.utf16.count))
|
||||
var currentLocation = 0
|
||||
|
||||
for (index, line) in source.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
|
||||
let length = line.utf16.count
|
||||
if clampedLocation <= currentLocation + length {
|
||||
return index
|
||||
}
|
||||
currentLocation += length + 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
public protocol EditorRenderer: Sendable {
|
||||
|
|
|
|||
90
Sources/SaplingEditor/EditorInstrumentation.swift
Normal file
90
Sources/SaplingEditor/EditorInstrumentation.swift
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import Foundation
|
||||
|
||||
public enum EditorRenderReason: String, Hashable, Codable, Sendable {
|
||||
case initial
|
||||
case sourceChange
|
||||
case activeLineChange
|
||||
case viewUpdate
|
||||
}
|
||||
|
||||
public struct EditorRenderPassMetric: Hashable, Sendable {
|
||||
public var reason: EditorRenderReason
|
||||
public var durationMilliseconds: Double
|
||||
public var characterCount: Int
|
||||
public var lineCount: Int
|
||||
public var activeLineIndex: Int
|
||||
|
||||
public init(
|
||||
reason: EditorRenderReason,
|
||||
durationMilliseconds: Double,
|
||||
characterCount: Int,
|
||||
lineCount: Int,
|
||||
activeLineIndex: Int
|
||||
) {
|
||||
self.reason = reason
|
||||
self.durationMilliseconds = durationMilliseconds
|
||||
self.characterCount = characterCount
|
||||
self.lineCount = lineCount
|
||||
self.activeLineIndex = activeLineIndex
|
||||
}
|
||||
}
|
||||
|
||||
public struct EditorInstrumentationSnapshot: Hashable, Sendable {
|
||||
public var sourceChangeCount: Int
|
||||
public var selectionChangeCount: Int
|
||||
public var activeLineChangeCount: Int
|
||||
public var renderPassCount: Int
|
||||
public var totalRenderDurationMilliseconds: Double
|
||||
public var lastRenderDurationMilliseconds: Double
|
||||
public var lastRenderCharacterCount: Int
|
||||
public var lastRenderLineCount: Int
|
||||
public var lastRenderReason: EditorRenderReason?
|
||||
|
||||
public init(
|
||||
sourceChangeCount: Int = 0,
|
||||
selectionChangeCount: Int = 0,
|
||||
activeLineChangeCount: Int = 0,
|
||||
renderPassCount: Int = 0,
|
||||
totalRenderDurationMilliseconds: Double = 0,
|
||||
lastRenderDurationMilliseconds: Double = 0,
|
||||
lastRenderCharacterCount: Int = 0,
|
||||
lastRenderLineCount: Int = 0,
|
||||
lastRenderReason: EditorRenderReason? = nil
|
||||
) {
|
||||
self.sourceChangeCount = sourceChangeCount
|
||||
self.selectionChangeCount = selectionChangeCount
|
||||
self.activeLineChangeCount = activeLineChangeCount
|
||||
self.renderPassCount = renderPassCount
|
||||
self.totalRenderDurationMilliseconds = totalRenderDurationMilliseconds
|
||||
self.lastRenderDurationMilliseconds = lastRenderDurationMilliseconds
|
||||
self.lastRenderCharacterCount = lastRenderCharacterCount
|
||||
self.lastRenderLineCount = lastRenderLineCount
|
||||
self.lastRenderReason = lastRenderReason
|
||||
}
|
||||
|
||||
public var averageRenderDurationMilliseconds: Double {
|
||||
guard renderPassCount > 0 else { return 0 }
|
||||
return totalRenderDurationMilliseconds / Double(renderPassCount)
|
||||
}
|
||||
|
||||
public mutating func recordSourceChange() {
|
||||
sourceChangeCount += 1
|
||||
}
|
||||
|
||||
public mutating func recordSelectionChange() {
|
||||
selectionChangeCount += 1
|
||||
}
|
||||
|
||||
public mutating func recordActiveLineChange() {
|
||||
activeLineChangeCount += 1
|
||||
}
|
||||
|
||||
public mutating func recordRenderPass(_ metric: EditorRenderPassMetric) {
|
||||
renderPassCount += 1
|
||||
totalRenderDurationMilliseconds += metric.durationMilliseconds
|
||||
lastRenderDurationMilliseconds = metric.durationMilliseconds
|
||||
lastRenderCharacterCount = metric.characterCount
|
||||
lastRenderLineCount = metric.lineCount
|
||||
lastRenderReason = metric.reason
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import UIKit
|
|||
@MainActor
|
||||
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
|
||||
@Published public private(set) var state: EditorState
|
||||
public private(set) var instrumentation = EditorInstrumentationSnapshot()
|
||||
|
||||
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
|
||||
self.state = EditorState(
|
||||
|
|
@ -34,16 +35,27 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
|
|||
|
||||
public func replaceDocument(_ document: EditorDocument) {
|
||||
state = EditorState(document: document)
|
||||
instrumentation = EditorInstrumentationSnapshot()
|
||||
}
|
||||
|
||||
public func updateSource(_ source: String) {
|
||||
guard state.document.source != source else { return }
|
||||
let previousActiveLineIndex = state.activeLineIndex
|
||||
state.updateSource(source)
|
||||
instrumentation.recordSourceChange()
|
||||
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
|
||||
}
|
||||
|
||||
public func updateSelection(_ selection: EditorSelection) {
|
||||
guard state.selection != selection else { return }
|
||||
let previousActiveLineIndex = state.activeLineIndex
|
||||
state.updateSelection(selection)
|
||||
instrumentation.recordSelectionChange()
|
||||
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
|
||||
}
|
||||
|
||||
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
|
||||
instrumentation.recordRenderPass(metric)
|
||||
}
|
||||
|
||||
public func save() throws {
|
||||
|
|
@ -56,6 +68,12 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
|
|||
let title = url.deletingPathExtension().lastPathComponent
|
||||
return MarkdownDocument(url: url, title: title, content: source)
|
||||
}
|
||||
|
||||
private func recordActiveLineChangeIfNeeded(_ previousActiveLineIndex: Int) {
|
||||
if previousActiveLineIndex != state.activeLineIndex {
|
||||
instrumentation.recordActiveLineChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct HybridMarkdownEditor: View, EditorView {
|
||||
|
|
@ -85,7 +103,8 @@ public struct HybridMarkdownEditor: View, EditorView {
|
|||
get: { viewModel.state.selection },
|
||||
set: { viewModel.updateSelection($0) }
|
||||
),
|
||||
activeLineIndex: viewModel.state.activeLineIndex
|
||||
activeLineIndex: viewModel.state.activeLineIndex,
|
||||
onRenderPass: viewModel.recordRenderPass
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
|
|
@ -128,6 +147,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
@Binding var text: String
|
||||
@Binding var selection: EditorSelection
|
||||
let activeLineIndex: Int
|
||||
let onRenderPass: (EditorRenderPassMetric) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
|
|
@ -222,7 +242,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
|
||||
parent.text = textView.string
|
||||
parent.selection = EditorSelection(range: textView.selectedRange())
|
||||
lastStyledText = nil
|
||||
applyHybridAttributes(to: textView)
|
||||
}
|
||||
|
||||
|
|
@ -240,8 +259,12 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
guard shouldRestyle(textView.string) else { return }
|
||||
|
||||
let selectedRange = textView.selectedRange()
|
||||
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
||||
let reason = renderReason(for: textView.string)
|
||||
let start = Date()
|
||||
var lineCount = 0
|
||||
performProgrammaticUpdate {
|
||||
MarkdownTextStyler.apply(
|
||||
lineCount = MarkdownTextStyler.apply(
|
||||
to: textStorage,
|
||||
activeLineIndex: parent.activeLineIndex,
|
||||
backgroundColor: .textBackgroundColor,
|
||||
|
|
@ -254,10 +277,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
selectedRange.location <= textView.string.utf16.count {
|
||||
textView.setSelectedRange(selectedRange)
|
||||
}
|
||||
restoreVisibleOrigin(visibleOrigin, in: textView)
|
||||
}
|
||||
|
||||
lastStyledText = textView.string
|
||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
||||
parent.onRenderPass(EditorRenderPassMetric(
|
||||
reason: reason,
|
||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||
characterCount: textView.string.utf16.count,
|
||||
lineCount: lineCount,
|
||||
activeLineIndex: parent.activeLineIndex
|
||||
))
|
||||
}
|
||||
|
||||
func setSelection(_ range: NSRange, in textView: NSTextView) {
|
||||
|
|
@ -301,6 +332,34 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
private func shouldRestyle(_ text: String) -> Bool {
|
||||
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
|
||||
}
|
||||
|
||||
private func renderReason(for text: String) -> EditorRenderReason {
|
||||
if lastStyledText == nil {
|
||||
return .initial
|
||||
}
|
||||
if lastStyledText != text {
|
||||
return .sourceChange
|
||||
}
|
||||
if lastStyledActiveLineIndex != parent.activeLineIndex {
|
||||
return .activeLineChange
|
||||
}
|
||||
return .viewUpdate
|
||||
}
|
||||
|
||||
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) {
|
||||
guard let origin,
|
||||
let scrollView = textView.enclosingScrollView
|
||||
else { return }
|
||||
|
||||
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
||||
let maxX = max(0, textView.bounds.width - scrollView.contentView.bounds.width)
|
||||
let clampedOrigin = NSPoint(
|
||||
x: max(0, min(origin.x, maxX)),
|
||||
y: max(0, min(origin.y, maxY))
|
||||
)
|
||||
scrollView.contentView.scroll(to: clampedOrigin)
|
||||
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -335,6 +394,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
@Binding var text: String
|
||||
@Binding var selection: EditorSelection
|
||||
let activeLineIndex: Int
|
||||
let onRenderPass: (EditorRenderPassMetric) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
|
|
@ -382,7 +442,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
guard !isPerformingProgrammaticUpdate else { return }
|
||||
parent.text = textView.text
|
||||
parent.selection = EditorSelection(range: textView.selectedRange)
|
||||
lastStyledText = nil
|
||||
applyHybridAttributes(to: textView)
|
||||
}
|
||||
|
||||
|
|
@ -398,8 +457,12 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
guard shouldRestyle(textView.text) else { return }
|
||||
|
||||
let selectedRange = textView.selectedRange
|
||||
let contentOffset = textView.contentOffset
|
||||
let reason = renderReason(for: textView.text)
|
||||
let start = Date()
|
||||
var lineCount = 0
|
||||
performProgrammaticUpdate {
|
||||
MarkdownTextStyler.apply(
|
||||
lineCount = MarkdownTextStyler.apply(
|
||||
to: textView.textStorage,
|
||||
activeLineIndex: parent.activeLineIndex,
|
||||
backgroundColor: .systemBackground,
|
||||
|
|
@ -411,12 +474,19 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
if textView.selectedRange != selectedRange,
|
||||
selectedRange.location <= textView.text.utf16.count {
|
||||
textView.selectedRange = selectedRange
|
||||
textView.scrollRangeToVisible(selectedRange)
|
||||
}
|
||||
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
|
||||
}
|
||||
|
||||
lastStyledText = textView.text
|
||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
||||
parent.onRenderPass(EditorRenderPassMetric(
|
||||
reason: reason,
|
||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||
characterCount: textView.text.utf16.count,
|
||||
lineCount: lineCount,
|
||||
activeLineIndex: parent.activeLineIndex
|
||||
))
|
||||
}
|
||||
|
||||
func performProgrammaticUpdate(_ updates: () -> Void) {
|
||||
|
|
@ -448,6 +518,25 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
private func shouldRestyle(_ text: String) -> Bool {
|
||||
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
|
||||
}
|
||||
|
||||
private func renderReason(for text: String) -> EditorRenderReason {
|
||||
if lastStyledText == nil {
|
||||
return .initial
|
||||
}
|
||||
if lastStyledText != text {
|
||||
return .sourceChange
|
||||
}
|
||||
if lastStyledActiveLineIndex != parent.activeLineIndex {
|
||||
return .activeLineChange
|
||||
}
|
||||
return .viewUpdate
|
||||
}
|
||||
|
||||
private func clampedContentOffset(_ offset: CGPoint, in textView: UITextView) -> CGPoint {
|
||||
let maxX = max(0, textView.contentSize.width - textView.bounds.width)
|
||||
let maxY = max(0, textView.contentSize.height - textView.bounds.height)
|
||||
return CGPoint(x: max(0, min(offset.x, maxX)), y: max(0, min(offset.y, maxY)))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -459,6 +548,7 @@ private enum MarkdownTextStyler {
|
|||
typealias PlatformColor = UIColor
|
||||
#endif
|
||||
|
||||
@discardableResult
|
||||
static func apply(
|
||||
to textStorage: NSTextStorage,
|
||||
activeLineIndex: Int,
|
||||
|
|
@ -467,15 +557,17 @@ private enum MarkdownTextStyler {
|
|||
textColor: PlatformColor,
|
||||
secondaryTextColor: PlatformColor,
|
||||
accentColor: PlatformColor
|
||||
) {
|
||||
) -> Int {
|
||||
let source = textStorage.string as NSString
|
||||
let fullRange = NSRange(location: 0, length: source.length)
|
||||
guard fullRange.length > 0 else { return }
|
||||
let lines = EditorActiveLineTracker.lines(from: source as String, activeLineIndex: activeLineIndex)
|
||||
guard fullRange.length > 0 else { return lines.count }
|
||||
|
||||
textStorage.beginEditing()
|
||||
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
|
||||
|
||||
for line in lines(in: source as String) {
|
||||
let renderer = HybridMarkdownLineRenderer()
|
||||
for line in lines {
|
||||
if line.index == activeLineIndex {
|
||||
textStorage.addAttributes([
|
||||
.backgroundColor: activeLineBackgroundColor,
|
||||
|
|
@ -486,8 +578,7 @@ private enum MarkdownTextStyler {
|
|||
styleRenderedLine(
|
||||
in: textStorage,
|
||||
line: line,
|
||||
source: source,
|
||||
textColor: textColor,
|
||||
renderPlan: renderer.renderPlan(for: line),
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor
|
||||
)
|
||||
|
|
@ -495,87 +586,57 @@ private enum MarkdownTextStyler {
|
|||
}
|
||||
|
||||
textStorage.endEditing()
|
||||
return lines.count
|
||||
}
|
||||
|
||||
private static func styleRenderedLine(
|
||||
in textStorage: NSTextStorage,
|
||||
line: EditorLine,
|
||||
source: NSString,
|
||||
textColor: PlatformColor,
|
||||
renderPlan: HybridMarkdownLineRenderPlan,
|
||||
secondaryTextColor: PlatformColor,
|
||||
accentColor: PlatformColor
|
||||
) {
|
||||
guard line.range.length > 0 else { return }
|
||||
|
||||
let rawLine = source.substring(with: line.range)
|
||||
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) {
|
||||
if case .heading(let level, let markerRange, let textRange) = renderPlan.kind {
|
||||
textStorage.addAttributes([
|
||||
.foregroundColor: secondaryTextColor,
|
||||
.font: monospacedFont(size: 13, weight: .regular)
|
||||
], range: heading.markerRange)
|
||||
], range: markerRange)
|
||||
textStorage.addAttributes([
|
||||
.font: systemFont(size: headingFontSize(level: heading.level), weight: .semibold)
|
||||
], range: heading.textRange)
|
||||
return
|
||||
.font: systemFont(size: headingFontSize(level: level), weight: .semibold)
|
||||
], range: textRange)
|
||||
}
|
||||
|
||||
if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("- [ ] ") {
|
||||
textStorage.addAttributes([
|
||||
.foregroundColor: secondaryTextColor,
|
||||
.font: monospacedFont(size: 14, weight: .regular)
|
||||
], range: NSRange(location: line.range.location, length: min(6, line.range.length)))
|
||||
}
|
||||
|
||||
styleInlineMarkdown(
|
||||
styleInlineSpans(
|
||||
in: textStorage,
|
||||
line: line,
|
||||
textColor: textColor,
|
||||
renderPlan: renderPlan,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor
|
||||
)
|
||||
}
|
||||
|
||||
private static func styleInlineMarkdown(
|
||||
private static func styleInlineSpans(
|
||||
in textStorage: NSTextStorage,
|
||||
line: EditorLine,
|
||||
textColor: PlatformColor,
|
||||
renderPlan: HybridMarkdownLineRenderPlan,
|
||||
secondaryTextColor: PlatformColor,
|
||||
accentColor: PlatformColor
|
||||
) {
|
||||
applyRegex("\\*\\*([^*]+)\\*\\*", in: textStorage, line: line) { match in
|
||||
guard match.numberOfRanges > 1 else { return }
|
||||
textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: match.range(at: 1))
|
||||
markdownDelimiterRanges(match.range, leading: 2, trailing: 2).forEach {
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
|
||||
for span in renderPlan.spans {
|
||||
switch span.kind {
|
||||
case .bold:
|
||||
textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: span.range)
|
||||
case .italic:
|
||||
textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: span.range)
|
||||
case .inlineCode:
|
||||
textStorage.addAttributes([
|
||||
.font: monospacedFont(size: 15, weight: .regular),
|
||||
.backgroundColor: accentColor.withAlphaComponent(0.12)
|
||||
], range: span.range)
|
||||
case .markdownDelimiter:
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range)
|
||||
}
|
||||
}
|
||||
|
||||
applyRegex("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in
|
||||
guard match.numberOfRanges > 1 else { return }
|
||||
textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: match.range(at: 1))
|
||||
markdownDelimiterRanges(match.range, leading: 1, trailing: 1).forEach {
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
|
||||
}
|
||||
}
|
||||
|
||||
applyRegex("\\[([^\\]]+)\\]\\(([^\\)]+)\\)", in: textStorage, line: line) { match in
|
||||
guard match.numberOfRanges > 2 else { return }
|
||||
textStorage.addAttributes([
|
||||
.foregroundColor: accentColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
], range: match.range(at: 1))
|
||||
let markerRanges = [
|
||||
NSRange(location: match.range.location, length: 1),
|
||||
NSRange(location: match.range(at: 1).upperBound, length: 2),
|
||||
NSRange(location: match.range(at: 2).upperBound, length: 1)
|
||||
]
|
||||
markerRanges.forEach {
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
|
||||
}
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range(at: 2))
|
||||
}
|
||||
}
|
||||
|
||||
private static func styleSourceLineMarkers(
|
||||
|
|
@ -583,7 +644,7 @@ private enum MarkdownTextStyler {
|
|||
line: EditorLine,
|
||||
secondaryTextColor: PlatformColor
|
||||
) {
|
||||
applyRegex("(```|#{1,6}|\\*\\*|\\*|\\[[ xX]\\]|\\[|\\]|\\(|\\))", in: textStorage, line: line) { match in
|
||||
applyRegex("(#{1,6}|\\*\\*|\\*|`)", in: textStorage, line: line) { match in
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
|
||||
}
|
||||
}
|
||||
|
|
@ -599,27 +660,6 @@ private enum MarkdownTextStyler {
|
|||
]
|
||||
}
|
||||
|
||||
private static func lines(in source: String) -> [EditorLine] {
|
||||
EditorState(document: EditorDocument(url: URL(fileURLWithPath: "/dev/null"), title: "", source: source)).lines
|
||||
}
|
||||
|
||||
private static func headingPrefixRange(
|
||||
in rawLine: String,
|
||||
lineRange: NSRange
|
||||
) -> (level: Int, markerRange: NSRange, textRange: NSRange)? {
|
||||
let markerCount = rawLine.prefix { $0 == "#" }.count
|
||||
guard (1...6).contains(markerCount),
|
||||
rawLine.dropFirst(markerCount).first == " "
|
||||
else { return nil }
|
||||
|
||||
let textOffset = markerCount + 1
|
||||
return (
|
||||
markerCount,
|
||||
NSRange(location: lineRange.location, length: markerCount),
|
||||
NSRange(location: lineRange.location + textOffset, length: max(0, lineRange.length - textOffset))
|
||||
)
|
||||
}
|
||||
|
||||
private static func applyRegex(
|
||||
_ pattern: String,
|
||||
in textStorage: NSTextStorage,
|
||||
|
|
@ -633,17 +673,6 @@ private enum MarkdownTextStyler {
|
|||
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
|
||||
}
|
||||
|
||||
private static func markdownDelimiterRanges(
|
||||
_ fullRange: NSRange,
|
||||
leading: Int,
|
||||
trailing: Int
|
||||
) -> [NSRange] {
|
||||
[
|
||||
NSRange(location: fullRange.location, length: leading),
|
||||
NSRange(location: fullRange.upperBound - trailing, length: trailing)
|
||||
]
|
||||
}
|
||||
|
||||
private static func headingFontSize(level: Int) -> CGFloat {
|
||||
switch level {
|
||||
case 1: 28
|
||||
|
|
|
|||
149
Sources/SaplingEditor/HybridMarkdownLineRenderer.swift
Normal file
149
Sources/SaplingEditor/HybridMarkdownLineRenderer.swift
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import Foundation
|
||||
|
||||
public enum HybridMarkdownLineKind: Hashable, Sendable {
|
||||
case paragraph
|
||||
case heading(level: Int, markerRange: NSRange, textRange: NSRange)
|
||||
}
|
||||
|
||||
public enum HybridMarkdownSpanKind: Hashable, Sendable {
|
||||
case bold
|
||||
case italic
|
||||
case inlineCode
|
||||
case markdownDelimiter
|
||||
}
|
||||
|
||||
public struct HybridMarkdownSpan: Hashable, Sendable {
|
||||
public var range: NSRange
|
||||
public var kind: HybridMarkdownSpanKind
|
||||
|
||||
public init(range: NSRange, kind: HybridMarkdownSpanKind) {
|
||||
self.range = range
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
public struct HybridMarkdownLineRenderPlan: Hashable, Sendable {
|
||||
public var line: EditorLine
|
||||
public var kind: HybridMarkdownLineKind
|
||||
public var spans: [HybridMarkdownSpan]
|
||||
|
||||
public init(line: EditorLine, kind: HybridMarkdownLineKind, spans: [HybridMarkdownSpan]) {
|
||||
self.line = line
|
||||
self.kind = kind
|
||||
self.spans = spans
|
||||
}
|
||||
}
|
||||
|
||||
public struct HybridMarkdownLineRenderer: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func renderPlan(for line: EditorLine) -> HybridMarkdownLineRenderPlan {
|
||||
let kind = lineKind(for: line)
|
||||
let spans = inlineSpans(in: line)
|
||||
return HybridMarkdownLineRenderPlan(line: line, kind: kind, spans: spans)
|
||||
}
|
||||
|
||||
private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind {
|
||||
let markerCount = line.source.prefix { $0 == "#" }.count
|
||||
guard (1...6).contains(markerCount),
|
||||
line.source.dropFirst(markerCount).first == " "
|
||||
else {
|
||||
return .paragraph
|
||||
}
|
||||
|
||||
let textOffset = markerCount + 1
|
||||
return .heading(
|
||||
level: markerCount,
|
||||
markerRange: NSRange(location: line.range.location, length: markerCount),
|
||||
textRange: NSRange(
|
||||
location: line.range.location + textOffset,
|
||||
length: max(0, line.range.length - textOffset)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func inlineSpans(in line: EditorLine) -> [HybridMarkdownSpan] {
|
||||
guard line.range.length > 0 else { return [] }
|
||||
|
||||
var spans: [HybridMarkdownSpan] = []
|
||||
var excludedRanges: [NSRange] = []
|
||||
|
||||
collectMatches("`([^`\\n]+)`", in: line, excluding: excludedRanges).forEach { match in
|
||||
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .inlineCode))
|
||||
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
|
||||
excludedRanges.append(match.fullRange)
|
||||
}
|
||||
|
||||
collectMatches("\\*\\*([^*\\n]+)\\*\\*", in: line, excluding: excludedRanges).forEach { match in
|
||||
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .bold))
|
||||
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 2, trailing: 2))
|
||||
excludedRanges.append(match.fullRange)
|
||||
}
|
||||
|
||||
collectMatches("(?<!\\*)\\*([^*\\n]+)\\*(?!\\*)", in: line, excluding: excludedRanges).forEach { match in
|
||||
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .italic))
|
||||
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
|
||||
excludedRanges.append(match.fullRange)
|
||||
}
|
||||
|
||||
return spans.sorted {
|
||||
if $0.range.location == $1.range.location {
|
||||
return $0.range.length < $1.range.length
|
||||
}
|
||||
return $0.range.location < $1.range.location
|
||||
}
|
||||
}
|
||||
|
||||
private func collectMatches(
|
||||
_ pattern: String,
|
||||
in line: EditorLine,
|
||||
excluding excludedRanges: [NSRange]
|
||||
) -> [InlineMatch] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
|
||||
return regex.matches(in: line.source, range: NSRange(location: 0, length: line.source.utf16.count))
|
||||
.compactMap { match in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
let shiftedRange = NSRange(
|
||||
location: line.range.location + match.range.location,
|
||||
length: match.range.length
|
||||
)
|
||||
guard !excludedRanges.contains(where: { $0.intersects(shiftedRange) }) else { return nil }
|
||||
return InlineMatch(
|
||||
fullRange: shiftedRange,
|
||||
contentRange: NSRange(
|
||||
location: line.range.location + match.range(at: 1).location,
|
||||
length: match.range(at: 1).length
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func delimiterRanges(
|
||||
_ fullRange: NSRange,
|
||||
leading: Int,
|
||||
trailing: Int
|
||||
) -> [HybridMarkdownSpan] {
|
||||
[
|
||||
HybridMarkdownSpan(range: NSRange(location: fullRange.location, length: leading), kind: .markdownDelimiter),
|
||||
HybridMarkdownSpan(
|
||||
range: NSRange(location: fullRange.upperBound - trailing, length: trailing),
|
||||
kind: .markdownDelimiter
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private struct InlineMatch {
|
||||
var fullRange: NSRange
|
||||
var contentRange: NSRange
|
||||
}
|
||||
|
||||
private extension NSRange {
|
||||
var upperBound: Int {
|
||||
location + length
|
||||
}
|
||||
|
||||
func intersects(_ other: NSRange) -> Bool {
|
||||
location < other.upperBound && other.location < upperBound
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,32 @@ final class EditorStateTests: XCTestCase {
|
|||
XCTAssertEqual(state.lines[2].mode, .rendered)
|
||||
}
|
||||
|
||||
func testSelectionIsClampedAfterDeletion() {
|
||||
let document = EditorDocument(
|
||||
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
|
||||
title: "EditorStateTests",
|
||||
source: "First line\nSecond line\nThird line"
|
||||
)
|
||||
var state = EditorState(document: document)
|
||||
state.updateSelection(EditorSelection(location: 30, length: 20))
|
||||
|
||||
state.updateSource("Short")
|
||||
|
||||
XCTAssertEqual(state.selection.location, 5)
|
||||
XCTAssertEqual(state.selection.length, 0)
|
||||
XCTAssertEqual(state.activeLineIndex, 0)
|
||||
XCTAssertEqual(state.lines.count, 1)
|
||||
}
|
||||
|
||||
func testLineTrackerPreservesTrailingBlankLine() {
|
||||
let lines = EditorActiveLineTracker.lines(from: "One\nTwo\n", activeLineIndex: 2)
|
||||
|
||||
XCTAssertEqual(lines.count, 3)
|
||||
XCTAssertEqual(lines[2].source, "")
|
||||
XCTAssertEqual(lines[2].range.location, 8)
|
||||
XCTAssertEqual(lines[2].mode, .source)
|
||||
}
|
||||
|
||||
func testUpdatingSourceTracksUnsavedChanges() {
|
||||
let document = EditorDocument(
|
||||
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
|
||||
|
|
@ -55,6 +81,103 @@ final class EditorStateTests: XCTestCase {
|
|||
XCTAssertEqual(state.activeColumnNumber, 4)
|
||||
}
|
||||
|
||||
func testHybridRendererSupportsMilestoneTwoInlineElements() {
|
||||
let source = "Plain **bold** and *italic* with `code`"
|
||||
let line = EditorLine(
|
||||
index: 0,
|
||||
source: source,
|
||||
range: NSRange(location: 0, length: source.utf16.count),
|
||||
mode: .rendered
|
||||
)
|
||||
|
||||
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
|
||||
|
||||
XCTAssertEqual(plan.kind, .paragraph)
|
||||
XCTAssertTrue(plan.spans.contains { $0.kind == .bold && (source as NSString).substring(with: $0.range) == "bold" })
|
||||
XCTAssertTrue(plan.spans.contains { $0.kind == .italic && (source as NSString).substring(with: $0.range) == "italic" })
|
||||
XCTAssertTrue(plan.spans.contains { $0.kind == .inlineCode && (source as NSString).substring(with: $0.range) == "code" })
|
||||
XCTAssertEqual(plan.spans.filter { $0.kind == .markdownDelimiter }.count, 6)
|
||||
}
|
||||
|
||||
func testHybridRendererSupportsHeadings() {
|
||||
let source = "## Architecture"
|
||||
let line = EditorLine(
|
||||
index: 0,
|
||||
source: source,
|
||||
range: NSRange(location: 12, length: source.utf16.count),
|
||||
mode: .rendered
|
||||
)
|
||||
|
||||
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
|
||||
|
||||
XCTAssertEqual(
|
||||
plan.kind,
|
||||
.heading(
|
||||
level: 2,
|
||||
markerRange: NSRange(location: 12, length: 2),
|
||||
textRange: NSRange(location: 15, length: 12)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testHybridRendererDoesNotPromoteLinksOrTasksInMilestoneTwo() {
|
||||
let source = "- [ ] Task with [link](https://example.com)"
|
||||
let line = EditorLine(
|
||||
index: 0,
|
||||
source: source,
|
||||
range: NSRange(location: 0, length: source.utf16.count),
|
||||
mode: .rendered
|
||||
)
|
||||
|
||||
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
|
||||
|
||||
XCTAssertEqual(plan.kind, .paragraph)
|
||||
XCTAssertTrue(plan.spans.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testViewModelTracksActiveLineAndSelectionInstrumentation() {
|
||||
let document = MarkdownDocument(
|
||||
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
|
||||
title: "EditorStateTests",
|
||||
content: "One\nTwo\nThree"
|
||||
)
|
||||
let viewModel = HybridMarkdownEditorViewModel(document: document)
|
||||
|
||||
viewModel.updateSelection(EditorSelection(location: 4, length: 0))
|
||||
viewModel.updateSource("One\nTwo updated\nThree")
|
||||
viewModel.recordRenderPass(EditorRenderPassMetric(
|
||||
reason: .activeLineChange,
|
||||
durationMilliseconds: 1.5,
|
||||
characterCount: 21,
|
||||
lineCount: 3,
|
||||
activeLineIndex: 1
|
||||
))
|
||||
|
||||
XCTAssertEqual(viewModel.instrumentation.selectionChangeCount, 1)
|
||||
XCTAssertEqual(viewModel.instrumentation.sourceChangeCount, 1)
|
||||
XCTAssertEqual(viewModel.instrumentation.activeLineChangeCount, 1)
|
||||
XCTAssertEqual(viewModel.instrumentation.renderPassCount, 1)
|
||||
XCTAssertEqual(viewModel.instrumentation.lastRenderReason, .activeLineChange)
|
||||
}
|
||||
|
||||
func testHybridRendererHandlesLargePrototypeDocumentShape() {
|
||||
let source = Self.prototypeDocument(lineCount: 2_100)
|
||||
let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 1_000)
|
||||
let renderer = HybridMarkdownLineRenderer()
|
||||
|
||||
let plans = lines.map(renderer.renderPlan(for:))
|
||||
|
||||
XCTAssertEqual(lines.count, 2_100)
|
||||
XCTAssertEqual(plans.filter {
|
||||
if case .heading = $0.kind {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}.count, 210)
|
||||
XCTAssertEqual(plans.flatMap(\.spans).filter { $0.kind == .inlineCode }.count, 210)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testViewModelSavesDocumentToDisk() throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
|
|
@ -72,4 +195,17 @@ final class EditorStateTests: XCTestCase {
|
|||
XCTAssertEqual(saved, "# Note\n\nUpdated")
|
||||
XCTAssertFalse(viewModel.hasUnsavedChanges)
|
||||
}
|
||||
|
||||
private static func prototypeDocument(lineCount: Int) -> String {
|
||||
(1...lineCount).map { index in
|
||||
if index.isMultiple(of: 10) {
|
||||
return "## Section \(index / 10)"
|
||||
}
|
||||
if index.isMultiple(of: 5) {
|
||||
return "Line \(index) uses **bold** context and `inline code` for scanning."
|
||||
}
|
||||
return "Line \(index) is a realistic paragraph with *light emphasis* and enough text to wrap naturally."
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue