diff --git a/Sources/SaplingEditor/EditorActiveLineTracker.swift b/Sources/SaplingEditor/EditorActiveLineTracker.swift new file mode 100644 index 0000000..904ecd8 --- /dev/null +++ b/Sources/SaplingEditor/EditorActiveLineTracker.swift @@ -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.. 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) + } +} diff --git a/Sources/SaplingEditor/EditorArchitecture.swift b/Sources/SaplingEditor/EditorArchitecture.swift index 590604e..4a94e33 100644 --- a/Sources/SaplingEditor/EditorArchitecture.swift +++ b/Sources/SaplingEditor/EditorArchitecture.swift @@ -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.. 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 { diff --git a/Sources/SaplingEditor/EditorInstrumentation.swift b/Sources/SaplingEditor/EditorInstrumentation.swift new file mode 100644 index 0000000..2ca6db9 --- /dev/null +++ b/Sources/SaplingEditor/EditorInstrumentation.swift @@ -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 + } +} diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index f9ad721..a52b4a3 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -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("(? 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 diff --git a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift new file mode 100644 index 0000000..064aaf4 --- /dev/null +++ b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift @@ -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("(? [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 + } +} diff --git a/Tests/SaplingEditorTests/EditorStateTests.swift b/Tests/SaplingEditorTests/EditorStateTests.swift index 270043f..f714601 100644 --- a/Tests/SaplingEditorTests/EditorStateTests.swift +++ b/Tests/SaplingEditorTests/EditorStateTests.swift @@ -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") + } }