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.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 {
|
||||||
|
|
|
||||||
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
|
@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
|
||||||
|
|
|
||||||
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)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue