feat(editor): validate hybrid active line rendering

This commit is contained in:
Feror 2026-05-29 20:08:46 +02:00
parent b2ae51d7a8
commit c458fb1529
6 changed files with 572 additions and 157 deletions

View 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)
}
}

View file

@ -96,7 +96,7 @@ public struct EditorState: Hashable, Sendable {
self.document = document self.document = document
self.selection = selection self.selection = selection
self.activeLineIndex = activeLineIndex self.activeLineIndex = activeLineIndex
self.lines = Self.makeLines( self.lines = EditorActiveLineTracker.lines(
from: document.source, from: document.source,
activeLineIndex: activeLineIndex activeLineIndex: activeLineIndex
) )
@ -115,73 +115,20 @@ public struct EditorState: Hashable, Sendable {
public mutating func updateSource(_ source: String) { public mutating func updateSource(_ source: String) {
document.source = source document.source = source
activeLineIndex = Self.lineIndex(containing: selection.location, in: source) selection = EditorActiveLineTracker.clampedSelection(selection, in: source)
lines = Self.makeLines(from: source, activeLineIndex: activeLineIndex) activeLineIndex = EditorActiveLineTracker.lineIndex(containing: selection.location, in: source)
lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex)
} }
public mutating func updateSelection(_ selection: EditorSelection) { public mutating func updateSelection(_ selection: EditorSelection) {
self.selection = selection self.selection = EditorActiveLineTracker.clampedSelection(selection, in: document.source)
activeLineIndex = Self.lineIndex(containing: selection.location, in: document.source) activeLineIndex = EditorActiveLineTracker.lineIndex(containing: self.selection.location, in: document.source)
lines = Self.makeLines(from: document.source, activeLineIndex: activeLineIndex) lines = EditorActiveLineTracker.lines(from: document.source, activeLineIndex: activeLineIndex)
} }
public mutating func markSaved() { public mutating func markSaved() {
document.lastSavedSource = document.source 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 { public protocol EditorRenderer: Sendable {

View 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
}
}

View file

@ -12,6 +12,7 @@ import UIKit
@MainActor @MainActor
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator { public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
@Published public private(set) var state: EditorState @Published public private(set) var state: EditorState
public private(set) var instrumentation = EditorInstrumentationSnapshot()
public init(document: MarkdownDocument, activeLineIndex: Int = 0) { public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
self.state = EditorState( self.state = EditorState(
@ -34,16 +35,27 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
public func replaceDocument(_ document: EditorDocument) { public func replaceDocument(_ document: EditorDocument) {
state = EditorState(document: document) state = EditorState(document: document)
instrumentation = EditorInstrumentationSnapshot()
} }
public func updateSource(_ source: String) { public func updateSource(_ source: String) {
guard state.document.source != source else { return } guard state.document.source != source else { return }
let previousActiveLineIndex = state.activeLineIndex
state.updateSource(source) state.updateSource(source)
instrumentation.recordSourceChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
} }
public func updateSelection(_ selection: EditorSelection) { public func updateSelection(_ selection: EditorSelection) {
guard state.selection != selection else { return } guard state.selection != selection else { return }
let previousActiveLineIndex = state.activeLineIndex
state.updateSelection(selection) state.updateSelection(selection)
instrumentation.recordSelectionChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
instrumentation.recordRenderPass(metric)
} }
public func save() throws { public func save() throws {
@ -56,6 +68,12 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
let title = url.deletingPathExtension().lastPathComponent let title = url.deletingPathExtension().lastPathComponent
return MarkdownDocument(url: url, title: title, content: source) return MarkdownDocument(url: url, title: title, content: source)
} }
private func recordActiveLineChangeIfNeeded(_ previousActiveLineIndex: Int) {
if previousActiveLineIndex != state.activeLineIndex {
instrumentation.recordActiveLineChange()
}
}
} }
public struct HybridMarkdownEditor: View, EditorView { public struct HybridMarkdownEditor: View, EditorView {
@ -85,7 +103,8 @@ public struct HybridMarkdownEditor: View, EditorView {
get: { viewModel.state.selection }, get: { viewModel.state.selection },
set: { viewModel.updateSelection($0) } set: { viewModel.updateSelection($0) }
), ),
activeLineIndex: viewModel.state.activeLineIndex activeLineIndex: viewModel.state.activeLineIndex,
onRenderPass: viewModel.recordRenderPass
) )
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@ -128,6 +147,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
@Binding var text: String @Binding var text: String
@Binding var selection: EditorSelection @Binding var selection: EditorSelection
let activeLineIndex: Int let activeLineIndex: Int
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(self) Coordinator(self)
@ -222,7 +242,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
parent.text = textView.string parent.text = textView.string
parent.selection = EditorSelection(range: textView.selectedRange()) parent.selection = EditorSelection(range: textView.selectedRange())
lastStyledText = nil
applyHybridAttributes(to: textView) applyHybridAttributes(to: textView)
} }
@ -240,8 +259,12 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
guard shouldRestyle(textView.string) else { return } guard shouldRestyle(textView.string) else { return }
let selectedRange = textView.selectedRange() let selectedRange = textView.selectedRange()
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
let reason = renderReason(for: textView.string)
let start = Date()
var lineCount = 0
performProgrammaticUpdate { performProgrammaticUpdate {
MarkdownTextStyler.apply( lineCount = MarkdownTextStyler.apply(
to: textStorage, to: textStorage,
activeLineIndex: parent.activeLineIndex, activeLineIndex: parent.activeLineIndex,
backgroundColor: .textBackgroundColor, backgroundColor: .textBackgroundColor,
@ -254,10 +277,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
selectedRange.location <= textView.string.utf16.count { selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange) textView.setSelectedRange(selectedRange)
} }
restoreVisibleOrigin(visibleOrigin, in: textView)
} }
lastStyledText = textView.string lastStyledText = textView.string
lastStyledActiveLineIndex = parent.activeLineIndex 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) { func setSelection(_ range: NSRange, in textView: NSTextView) {
@ -301,6 +332,34 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
private func shouldRestyle(_ text: String) -> Bool { private func shouldRestyle(_ text: String) -> Bool {
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex 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 text: String
@Binding var selection: EditorSelection @Binding var selection: EditorSelection
let activeLineIndex: Int let activeLineIndex: Int
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(self) Coordinator(self)
@ -382,7 +442,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
guard !isPerformingProgrammaticUpdate else { return } guard !isPerformingProgrammaticUpdate else { return }
parent.text = textView.text parent.text = textView.text
parent.selection = EditorSelection(range: textView.selectedRange) parent.selection = EditorSelection(range: textView.selectedRange)
lastStyledText = nil
applyHybridAttributes(to: textView) applyHybridAttributes(to: textView)
} }
@ -398,8 +457,12 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
guard shouldRestyle(textView.text) else { return } guard shouldRestyle(textView.text) else { return }
let selectedRange = textView.selectedRange let selectedRange = textView.selectedRange
let contentOffset = textView.contentOffset
let reason = renderReason(for: textView.text)
let start = Date()
var lineCount = 0
performProgrammaticUpdate { performProgrammaticUpdate {
MarkdownTextStyler.apply( lineCount = MarkdownTextStyler.apply(
to: textView.textStorage, to: textView.textStorage,
activeLineIndex: parent.activeLineIndex, activeLineIndex: parent.activeLineIndex,
backgroundColor: .systemBackground, backgroundColor: .systemBackground,
@ -411,12 +474,19 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
if textView.selectedRange != selectedRange, if textView.selectedRange != selectedRange,
selectedRange.location <= textView.text.utf16.count { selectedRange.location <= textView.text.utf16.count {
textView.selectedRange = selectedRange textView.selectedRange = selectedRange
textView.scrollRangeToVisible(selectedRange)
} }
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
} }
lastStyledText = textView.text lastStyledText = textView.text
lastStyledActiveLineIndex = parent.activeLineIndex 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) { func performProgrammaticUpdate(_ updates: () -> Void) {
@ -448,6 +518,25 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
private func shouldRestyle(_ text: String) -> Bool { private func shouldRestyle(_ text: String) -> Bool {
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex 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 #endif
@ -459,6 +548,7 @@ private enum MarkdownTextStyler {
typealias PlatformColor = UIColor typealias PlatformColor = UIColor
#endif #endif
@discardableResult
static func apply( static func apply(
to textStorage: NSTextStorage, to textStorage: NSTextStorage,
activeLineIndex: Int, activeLineIndex: Int,
@ -467,15 +557,17 @@ private enum MarkdownTextStyler {
textColor: PlatformColor, textColor: PlatformColor,
secondaryTextColor: PlatformColor, secondaryTextColor: PlatformColor,
accentColor: PlatformColor accentColor: PlatformColor
) { ) -> Int {
let source = textStorage.string as NSString let source = textStorage.string as NSString
let fullRange = NSRange(location: 0, length: source.length) 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.beginEditing()
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange) 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 { if line.index == activeLineIndex {
textStorage.addAttributes([ textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor, .backgroundColor: activeLineBackgroundColor,
@ -486,8 +578,7 @@ private enum MarkdownTextStyler {
styleRenderedLine( styleRenderedLine(
in: textStorage, in: textStorage,
line: line, line: line,
source: source, renderPlan: renderer.renderPlan(for: line),
textColor: textColor,
secondaryTextColor: secondaryTextColor, secondaryTextColor: secondaryTextColor,
accentColor: accentColor accentColor: accentColor
) )
@ -495,86 +586,56 @@ private enum MarkdownTextStyler {
} }
textStorage.endEditing() textStorage.endEditing()
return lines.count
} }
private static func styleRenderedLine( private static func styleRenderedLine(
in textStorage: NSTextStorage, in textStorage: NSTextStorage,
line: EditorLine, line: EditorLine,
source: NSString, renderPlan: HybridMarkdownLineRenderPlan,
textColor: PlatformColor,
secondaryTextColor: PlatformColor, secondaryTextColor: PlatformColor,
accentColor: PlatformColor accentColor: PlatformColor
) { ) {
guard line.range.length > 0 else { return } guard line.range.length > 0 else { return }
let rawLine = source.substring(with: line.range) if case .heading(let level, let markerRange, let textRange) = renderPlan.kind {
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) {
textStorage.addAttributes([ textStorage.addAttributes([
.foregroundColor: secondaryTextColor, .foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 13, weight: .regular) .font: monospacedFont(size: 13, weight: .regular)
], range: heading.markerRange) ], range: markerRange)
textStorage.addAttributes([ textStorage.addAttributes([
.font: systemFont(size: headingFontSize(level: heading.level), weight: .semibold) .font: systemFont(size: headingFontSize(level: level), weight: .semibold)
], range: heading.textRange) ], range: textRange)
return
} }
if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("- [ ] ") { styleInlineSpans(
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 14, weight: .regular)
], range: NSRange(location: line.range.location, length: min(6, line.range.length)))
}
styleInlineMarkdown(
in: textStorage, in: textStorage,
line: line, renderPlan: renderPlan,
textColor: textColor,
secondaryTextColor: secondaryTextColor, secondaryTextColor: secondaryTextColor,
accentColor: accentColor accentColor: accentColor
) )
} }
private static func styleInlineMarkdown( private static func styleInlineSpans(
in textStorage: NSTextStorage, in textStorage: NSTextStorage,
line: EditorLine, renderPlan: HybridMarkdownLineRenderPlan,
textColor: PlatformColor,
secondaryTextColor: PlatformColor, secondaryTextColor: PlatformColor,
accentColor: PlatformColor accentColor: PlatformColor
) { ) {
applyRegex("\\*\\*([^*]+)\\*\\*", in: textStorage, line: line) { match in for span in renderPlan.spans {
guard match.numberOfRanges > 1 else { return } switch span.kind {
textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: match.range(at: 1)) case .bold:
markdownDelimiterRanges(match.range, leading: 2, trailing: 2).forEach { textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: span.range)
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0) case .italic:
} textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: span.range)
} case .inlineCode:
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([ textStorage.addAttributes([
.foregroundColor: accentColor, .font: monospacedFont(size: 15, weight: .regular),
.underlineStyle: NSUnderlineStyle.single.rawValue .backgroundColor: accentColor.withAlphaComponent(0.12)
], range: match.range(at: 1)) ], range: span.range)
let markerRanges = [ case .markdownDelimiter:
NSRange(location: match.range.location, length: 1), textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range)
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))
} }
} }
@ -583,7 +644,7 @@ private enum MarkdownTextStyler {
line: EditorLine, line: EditorLine,
secondaryTextColor: PlatformColor 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) 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( private static func applyRegex(
_ pattern: String, _ pattern: String,
in textStorage: NSTextStorage, in textStorage: NSTextStorage,
@ -633,17 +673,6 @@ private enum MarkdownTextStyler {
regex.matches(in: textStorage.string, range: line.range).forEach(handler) 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 { private static func headingFontSize(level: Int) -> CGFloat {
switch level { switch level {
case 1: 28 case 1: 28

View 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
}
}

View file

@ -21,6 +21,32 @@ final class EditorStateTests: XCTestCase {
XCTAssertEqual(state.lines[2].mode, .rendered) 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() { func testUpdatingSourceTracksUnsavedChanges() {
let document = EditorDocument( let document = EditorDocument(
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"), url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
@ -55,6 +81,103 @@ final class EditorStateTests: XCTestCase {
XCTAssertEqual(state.activeColumnNumber, 4) 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 @MainActor
func testViewModelSavesDocumentToDisk() throws { func testViewModelSavesDocumentToDisk() throws {
let directory = FileManager.default.temporaryDirectory let directory = FileManager.default.temporaryDirectory
@ -72,4 +195,17 @@ final class EditorStateTests: XCTestCase {
XCTAssertEqual(saved, "# Note\n\nUpdated") XCTAssertEqual(saved, "# Note\n\nUpdated")
XCTAssertFalse(viewModel.hasUnsavedChanges) 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")
}
} }