From 941fcd5d5603d5d2c3ad24433a6959bb314c4e15 Mon Sep 17 00:00:00 2001 From: Feror Date: Mon, 1 Jun 2026 10:17:17 +0200 Subject: [PATCH] feat(editor): support editable regions and code blocks --- Docs/editor-investigation.md | 81 +++++++ .../DocumentPresentationState.swift | 28 ++- Sources/SaplingEditor/EditableRegion.swift | 125 ++++++++++ .../EditorDirtyLineInvalidation.swift | 24 ++ .../SaplingEditor/HybridMarkdownEditor.swift | 217 +++++++++++++++--- .../HybridMarkdownLineRenderer.swift | 57 +++-- .../DocumentPresentationStateTests.swift | 37 ++- .../SaplingEditorTests/EditorStateTests.swift | 2 +- ...HybridMarkdownLiveEditorHarnessTests.swift | 44 ++++ .../MarkdownTextStylerRenderingTests.swift | 19 +- 10 files changed, 585 insertions(+), 49 deletions(-) create mode 100644 Sources/SaplingEditor/EditableRegion.swift diff --git a/Docs/editor-investigation.md b/Docs/editor-investigation.md index 9355f9e..25efb01 100644 --- a/Docs/editor-investigation.md +++ b/Docs/editor-investigation.md @@ -1407,6 +1407,87 @@ Corrected invalidation path: The live regression `testInitialChecklistOverlayTracksFirstLiveLayoutPass` reproduces the escaped bug by creating the real editor harness, recording checkbox-to-label spacing, applying the first realistic scroll-view layout change, and verifying that every checklist overlay still tracks its label. Before the fix, the label gap changed from the initial rendered value to a different post-layout value because the label moved with the text container while the button did not. +## Finding #22 — Editable Regions + +Milestone 3.6 replaces the presentation assumption of one active source line with an editable region. The underlying editor still keeps one canonical Markdown buffer in `NSTextView` / `UITextView`; only the presentation decision changed. + +Editable region model: + +```mermaid +flowchart LR + Selection["Native selection range"] --> Region["EditableRegion"] + Region --> Lines["Source-mode line indexes"] + Lines --> Presentation["DocumentPresentationState"] + Presentation --> Styler["MarkdownTextStyler"] + Styler --> TextKit["Text storage attributes"] +``` + +Rules: + +- A collapsed cursor creates a one-line editable region. +- A multi-line selection creates a contiguous editable region covering every selected line. +- If the selection intersects a fenced code block, the editable region expands to the full fenced block. +- Lines outside the editable region remain rendered. + +The active line still exists as a compatibility metric and status-bar concept, but rendering no longer depends on a single active line. `DocumentPresentationState` now accepts an `EditableRegion` and marks every line in that region as `.source`. Existing active-line initializers remain as convenience wrappers for older tests and instrumentation. + +Invalidation tradeoff: + +Editable-region transitions dirty the previous and current region line sets. This is intentionally broader than the old active-line pair, but still bounded by the selection/block size rather than the whole document. Typing still uses the incremental `DocumentLineIndexEdit` path, so large-document behavior remains tied to dirty lines instead of document-wide presentation work. + +Validation added: + +- `testEditableRegionMakesMultipleSelectedLinesSource` +- `testEditableRegionExpandsToEntireCodeBlock` +- `testLiveMultiLineSelectionUsesEditableRegion` +- `testLiveCodeBlockSelectionUsesWholeBlockEditableRegion` + +These tests cover both the semantic presentation model and the live `NSTextView` coordinator path. + +Benchmark validation after Milestone 3.6: + +| Scenario | Total | Dirty typing render | Dirty lines | +| --- | ---: | ---: | ---: | +| sample document | 13.801 ms | 0.312 ms | 3 | +| 2,100-line prototype | 429.797 ms | 0.332 ms | 3 | +| 5 MB benchmark | 3,367.834 ms | 0.992 ms | 3 | + +The editable-region model does not introduce document-wide work for ordinary typing. Region transitions dirty the previous and current editable lines, while typing remains on the incremental dirty-line path. + +## Finding #23 — Code Blocks as Block Elements + +Code blocks now behave as block-level editing units and have a stronger rendered presentation. + +Rendered behavior: + +- Opening and closing fences are hidden in rendered mode. +- The fence language remains visible as a compact label when present. +- Code content uses a monospaced font. +- Code block paragraphs receive a tinted background, indentation, and tighter spacing. +- Code content receives modest syntax highlighting. + +Editable behavior: + +```mermaid +stateDiagram-v2 + [*] --> RenderedBlock + RenderedBlock --> SourceBlock: Cursor or selection enters fenced block + SourceBlock --> RenderedBlock: Selection leaves fenced block + SourceBlock --> SourceBlock: Editing inside any block line +``` + +The full block switches to source mode whenever the editable region touches any fence or content line. This avoids partial states such as a rendered opening fence with editable code content, which made code blocks feel inconsistent and made cursor behavior harder to reason about. + +Syntax highlighting scope: + +- Swift: keywords, strings, numbers, and line comments. +- JSON: keys, strings, booleans/null, and numbers. +- YAML: keys, comments, and common scalar literals. +- Markdown: headings and common inline constructs. +- Plain text: monospaced fallback without extra token coloring. + +The highlighter is deliberately line-local and regex-based. It does not attempt IDE-level parsing, multi-line string state, or semantic analysis. That keeps highlighting cheap enough to run inside dirty-line presentation passes and keeps the architecture extensible for a later proper syntax engine. + ## AttributedString and NSAttributedString Swift `AttributedString` is useful for renderer-facing APIs and SwiftUI previews. diff --git a/Sources/SaplingEditor/DocumentPresentationState.swift b/Sources/SaplingEditor/DocumentPresentationState.swift index 0dd0977..1780fe3 100644 --- a/Sources/SaplingEditor/DocumentPresentationState.swift +++ b/Sources/SaplingEditor/DocumentPresentationState.swift @@ -146,8 +146,8 @@ public struct DocumentRenderModel: Hashable, Sendable { return "task:\(describe(markerRange)):\(describe(checkboxRange)):\(describe(contentRange)):\(checked):\(nestingLevel)" case .fencedCodeFence(let markerRange, let languageRange): return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil")" - case .codeBlockContent: - return "codeContent" + case .codeBlockContent(let language): + return "codeContent:\(language ?? "plain")" case .tableRow(let cellRanges, let separatorRanges, let isDivider): return "table:\(cellRanges.map(describe).joined(separator: ",")):" + "\(separatorRanges.map(describe).joined(separator: ",")):\(isDivider)" @@ -198,6 +198,7 @@ public struct DocumentPresentationLine: Hashable, Sendable { public struct DocumentPresentationState: Hashable, Sendable { public var activeLineIndex: Int + public var editableRegion: EditableRegion public var lineCount: Int public var lines: [DocumentPresentationLine] public var elements: [RenderedDocumentElement] @@ -207,17 +208,30 @@ public struct DocumentPresentationState: Hashable, Sendable { activeLineIndex: Int, lineIndexes: [Int]? = nil ) { - self.activeLineIndex = activeLineIndex + self.init( + lineIndex: lineIndex, + editableRegion: activeLineIndex >= 0 ? EditableRegion(lineIndexes: [activeLineIndex]) : .none(), + lineIndexes: lineIndexes + ) + } + + public init( + lineIndex: DocumentLineIndex, + editableRegion: EditableRegion, + lineIndexes: [Int]? = nil + ) { + self.activeLineIndex = editableRegion.primaryLineIndex + self.editableRegion = editableRegion self.lineCount = lineIndex.lineCount let renderer = HybridMarkdownLineRenderer() let selectedLines = lineIndexes.map { - lineIndex.editorLines(for: $0, activeLineIndex: activeLineIndex) - } ?? lineIndex.editorLines(activeLineIndex: activeLineIndex) + lineIndex.editorLines(for: $0, activeLineIndex: editableRegion.primaryLineIndex) + } ?? lineIndex.editorLines(activeLineIndex: editableRegion.primaryLineIndex) let renderPlans = Self.renderPlans( for: selectedLines, lineIndex: lineIndex, - activeLineIndex: activeLineIndex, + activeLineIndex: editableRegion.primaryLineIndex, renderer: renderer, needsDocumentContext: lineIndexes != nil ) @@ -226,7 +240,7 @@ public struct DocumentPresentationState: Hashable, Sendable { var collectedElements: [RenderedDocumentElement] = [] for line in selectedLines { - let state: RenderedLineState = line.index == activeLineIndex ? .source : .rendered + let state: RenderedLineState = editableRegion.contains(line.index) ? .source : .rendered let renderPlan = renderPlansByLine[line.index] ?? renderer.renderPlan(for: line) let lineElements = Self.elements(for: renderPlan, state: state) presentationLines.append(DocumentPresentationLine( diff --git a/Sources/SaplingEditor/EditableRegion.swift b/Sources/SaplingEditor/EditableRegion.swift new file mode 100644 index 0000000..0594ae3 --- /dev/null +++ b/Sources/SaplingEditor/EditableRegion.swift @@ -0,0 +1,125 @@ +import Foundation + +public struct EditableRegion: Hashable, Sendable { + public var lineIndexes: [Int] + + public init(lineIndexes: some Sequence) { + self.lineIndexes = Array(Set(lineIndexes)).sorted() + } + + public var primaryLineIndex: Int { + lineIndexes.first ?? -1 + } + + public var isEmpty: Bool { + lineIndexes.isEmpty + } + + public func contains(_ lineIndex: Int) -> Bool { + lineIndexes.binarySearch(lineIndex) + } + + public static func none() -> EditableRegion { + EditableRegion(lineIndexes: []) + } + + public static func selection(_ selection: NSRange, in lineIndex: DocumentLineIndex) -> EditableRegion { + guard lineIndex.lineCount > 0 else { + return .none() + } + + let sourceLength = lineIndex.source.utf16.count + let startLocation = max(0, min(selection.location, sourceLength)) + let endLocation: Int + if selection.length == 0 { + endLocation = startLocation + } else { + endLocation = max(startLocation, min(selection.upperBound - 1, sourceLength)) + } + + let startLine = lineIndex.lineIndex(containing: startLocation) + let endLine = lineIndex.lineIndex(containing: endLocation) + let selectedRange = min(startLine, endLine)...max(startLine, endLine) + var editableLines = Set(selectedRange) + guard lineIndex.source.contains("```") || lineIndex.source.contains("~~~") else { + return EditableRegion(lineIndexes: editableLines) + } + + for selectedLine in selectedRange { + if let codeBlockRange = lineIndex.fencedCodeBlockLineRange(containing: selectedLine) { + editableLines.formUnion(codeBlockRange) + } + } + + return EditableRegion(lineIndexes: editableLines) + } +} + +private extension DocumentLineIndex { + func fencedCodeBlockLineRange(containing lineIndex: Int) -> ClosedRange? { + guard (0.. String? { + let indentation = source.prefix { $0 == " " || $0 == "\t" } + guard indentation.count <= 3 else { return nil } + + let content = source.dropFirst(indentation.count) + if content.hasPrefix("```") { + return "```" + } + if content.hasPrefix("~~~") { + return "~~~" + } + return nil + } +} + +private extension Array where Element == Int { + func binarySearch(_ value: Int) -> Bool { + var lowerBound = 0 + var upperBound = count - 1 + while lowerBound <= upperBound { + let midpoint = (lowerBound + upperBound) / 2 + if self[midpoint] == value { + return true + } + if self[midpoint] < value { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint - 1 + } + } + return false + } +} + +private extension NSRange { + var upperBound: Int { + location + length + } +} diff --git a/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift b/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift index 157b8d5..0fd0853 100644 --- a/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift +++ b/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift @@ -186,6 +186,30 @@ public enum EditorDirtyLineInvalidator { } } +public extension EditorDirtyLineInvalidationPlan { + func includingEditableRegionTransition( + from previousRegion: EditableRegion?, + to currentRegion: EditableRegion, + lineCount: Int + ) -> EditorDirtyLineInvalidationPlan { + guard !isFullRender else { return self } + guard previousRegion != currentRegion else { return self } + + var dirtyLineIndexes = Set(self.dirtyLineIndexes) + dirtyLineIndexes.formUnion(previousRegion?.lineIndexes ?? []) + dirtyLineIndexes.formUnion(currentRegion.lineIndexes) + + return EditorDirtyLineInvalidationPlan( + reason: reason, + isFullRender: false, + dirtyLineIndexes: dirtyLineIndexes + .filter { (0.. Int { + private func presentationEditableRegion(in textView: NSTextView) -> EditableRegion { guard hasUserActivatedEditing, textView.window?.firstResponder === textView - else { return -1 } - return currentLineIndex.lineIndex(containing: textView.selectedRange().location) + else { return .none() } + return EditableRegion.selection(textView.selectedRange(), in: currentLineIndex) } func activateEditingPresentation(in textView: NSTextView) { @@ -397,6 +401,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { func invalidateStylingCache() { lastStyledText = nil lastStyledActiveLineIndex = nil + lastStyledEditableRegion = nil hasUserActivatedEditing = false removeChecklistControls() } @@ -405,13 +410,19 @@ private struct NativeMarkdownTextView: NSViewRepresentable { programmaticUpdateDepth > 0 } - private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan { - EditorDirtyLineInvalidator.plan( + private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan { + let previousEditableRegion = lastStyledEditableRegion + let plan = EditorDirtyLineInvalidator.plan( previousText: lastStyledText, currentLineIndex: currentLineIndex, edit: pendingEdit, - previousActiveLineIndex: lastStyledActiveLineIndex, - currentActiveLineIndex: activeLineIndex + previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex, + currentActiveLineIndex: editableRegion.primaryLineIndex + ) + return plan.includingEditableRegionTransition( + from: previousEditableRegion, + to: editableRegion, + lineCount: currentLineIndex.lineCount ) } @@ -813,6 +824,10 @@ public final class HybridMarkdownLiveEditorHarness { isHidden(at: 0) } + public func characterIsHidden(at location: Int) -> Bool { + isHidden(at: location) + } + public func point(for text: String) -> CGPoint? { let textRange = (textView.string as NSString).range(of: text) guard textRange.location != NSNotFound, @@ -975,6 +990,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable { private var programmaticUpdateDepth = 0 private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? + private var lastStyledEditableRegion: EditableRegion? private var pendingEdit: DocumentLineIndexEdit? init(_ parent: NativeMarkdownTextView) { self.parent = parent @@ -1014,10 +1030,11 @@ private struct NativeMarkdownTextView: UIViewRepresentable { } func applyHybridAttributes(to textView: UITextView) { - let activeLineIndex = textView.isFirstResponder - ? currentLineIndex.lineIndex(containing: textView.selectedRange.location) - : -1 - let invalidationPlan = invalidationPlan(for: textView.text, activeLineIndex: activeLineIndex) + let editableRegion = textView.isFirstResponder + ? EditableRegion.selection(textView.selectedRange, in: currentLineIndex) + : .none() + let activeLineIndex = editableRegion.primaryLineIndex + let invalidationPlan = invalidationPlan(for: textView.text, editableRegion: editableRegion) guard invalidationPlan.requiresStyling else { return } let selectedRange = textView.selectedRange @@ -1035,7 +1052,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable { activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10), textColor: .label, secondaryTextColor: .secondaryLabel, - accentColor: .systemBlue + accentColor: .systemBlue, + editableRegion: editableRegion ) if textView.selectedRange != selectedRange, selectedRange.location <= textView.text.utf16.count { @@ -1047,6 +1065,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable { lastStyledText = textView.text lastStyledActiveLineIndex = activeLineIndex + lastStyledEditableRegion = editableRegion parent.onRenderPass(EditorRenderPassMetric( reason: invalidationPlan.reason, durationMilliseconds: Date().timeIntervalSince(start) * 1000, @@ -1078,19 +1097,26 @@ private struct NativeMarkdownTextView: UIViewRepresentable { func invalidateStylingCache() { lastStyledText = nil lastStyledActiveLineIndex = nil + lastStyledEditableRegion = nil } private var isPerformingProgrammaticUpdate: Bool { programmaticUpdateDepth > 0 } - private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan { - EditorDirtyLineInvalidator.plan( + private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan { + let previousEditableRegion = lastStyledEditableRegion + let plan = EditorDirtyLineInvalidator.plan( previousText: lastStyledText, currentLineIndex: currentLineIndex, edit: pendingEdit, - previousActiveLineIndex: lastStyledActiveLineIndex, - currentActiveLineIndex: activeLineIndex + previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex, + currentActiveLineIndex: editableRegion.primaryLineIndex + ) + return plan.includingEditableRegionTransition( + from: previousEditableRegion, + to: editableRegion, + lineCount: currentLineIndex.lineCount ) } @@ -1108,12 +1134,14 @@ struct MarkdownTextStylingResult { var styledLineCount: Int var styledLineIndexes: [Int] var renderedTasks: [RenderedTaskElement] + var editableRegion: EditableRegion static let empty = MarkdownTextStylingResult( totalLineCount: 0, styledLineCount: 0, styledLineIndexes: [], - renderedTasks: [] + renderedTasks: [], + editableRegion: .none() ) } @@ -1135,15 +1163,19 @@ enum MarkdownTextStyler { textColor: PlatformColor, secondaryTextColor: PlatformColor, accentColor: PlatformColor, - usesRenderedControls: Bool = false + usesRenderedControls: Bool = false, + editableRegion: EditableRegion? = nil ) -> MarkdownTextStylingResult { + let resolvedEditableRegion = editableRegion + ?? (activeLineIndex >= 0 ? EditableRegion(lineIndexes: [activeLineIndex]) : .none()) let fullRange = NSRange(location: 0, length: textStorage.length) guard fullRange.length > 0 else { return MarkdownTextStylingResult( totalLineCount: lineIndex.lineCount, styledLineCount: lineIndex.lineCount, styledLineIndexes: Array(0.. String { + let normalized = language?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "swift", "json", "yaml", "yml": + return normalized == "yml" ? "yaml" : normalized ?? "text" + case "md", "markdown": + return "markdown" + default: + return "text" + } + } + private static func styleSourceLineMarkers( in textStorage: NSTextStorage, line: EditorLine, @@ -1409,6 +1534,19 @@ enum MarkdownTextStyler { regex.matches(in: textStorage.string, range: line.range).forEach(handler) } + private static func applyCodeRegex( + _ pattern: String, + in textStorage: NSTextStorage, + line: EditorLine, + handler: (NSTextCheckingResult) -> Void + ) { + guard line.range.length > 0, + let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) + else { return } + + regex.matches(in: textStorage.string, range: line.range).forEach(handler) + } + private static func headingFontSize(level: Int) -> CGFloat { switch level { case 1: 28 @@ -1463,7 +1601,10 @@ enum MarkdownTextStyler { private static func codeBlockParagraphStyle() -> NSMutableParagraphStyle { let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 3 - paragraph.paragraphSpacing = 2 + paragraph.paragraphSpacing = 4 + paragraph.paragraphSpacingBefore = 2 + paragraph.firstLineHeadIndent = 14 + paragraph.headIndent = 14 return paragraph } @@ -1565,6 +1706,18 @@ enum MarkdownTextStyler { private static func clearColor() -> NSColor { .clear } + + private static func codeKeywordColor(accentColor: NSColor) -> NSColor { + .systemPurple + } + + private static func codeStringColor(accentColor: NSColor) -> NSColor { + .systemRed + } + + private static func codeNumberColor(accentColor: NSColor) -> NSColor { + .systemOrange + } #elseif os(iOS) private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont { UIFont.systemFont(ofSize: size, weight: weight) @@ -1581,6 +1734,18 @@ enum MarkdownTextStyler { private static func clearColor() -> UIColor { .clear } + + private static func codeKeywordColor(accentColor: UIColor) -> UIColor { + .systemPurple + } + + private static func codeStringColor(accentColor: UIColor) -> UIColor { + .systemRed + } + + private static func codeNumberColor(accentColor: UIColor) -> UIColor { + .systemOrange + } #endif } diff --git a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift index 974289f..8048184 100644 --- a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift +++ b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift @@ -15,7 +15,7 @@ public enum HybridMarkdownLineKind: Hashable, Sendable { nestingLevel: Int ) case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?) - case codeBlockContent + case codeBlockContent(language: String?) case tableRow(cellRanges: [NSRange], separatorRanges: [NSRange], isDivider: Bool) } @@ -61,17 +61,24 @@ public struct HybridMarkdownLineRenderer: Sendable { public func renderPlans(for lines: [EditorLine]) -> [HybridMarkdownLineRenderPlan] { var isInCodeBlock = false + var codeBlockLanguage: String? var plans: [HybridMarkdownLineRenderPlan] = [] for line in lines { let nonCodeKind = lineKind(for: line) let kind: HybridMarkdownLineKind - if case .fencedCodeFence = nonCodeKind { + if case .fencedCodeFence(_, let languageRange) = nonCodeKind { kind = nonCodeKind - isInCodeBlock.toggle() + if isInCodeBlock { + isInCodeBlock = false + codeBlockLanguage = nil + } else { + isInCodeBlock = true + codeBlockLanguage = language(in: line, range: languageRange) + } } else if isInCodeBlock { - kind = .codeBlockContent + kind = .codeBlockContent(language: codeBlockLanguage) } else { kind = nonCodeKind } @@ -97,8 +104,12 @@ public struct HybridMarkdownLineRenderer: Sendable { if case .fencedCodeFence = nonCodeKind { kind = nonCodeKind - } else if isInsideFencedCodeBlock(line.index, in: lineIndex, activeLineIndex: activeLineIndex) { - kind = .codeBlockContent + } else if let context = fencedCodeBlockContext( + containing: line.index, + in: lineIndex, + activeLineIndex: activeLineIndex + ) { + kind = .codeBlockContent(language: context.language) } else { kind = nonCodeKind } @@ -506,24 +517,44 @@ public struct HybridMarkdownLineRenderer: Sendable { source.first { $0 != " " && $0 != "\t" } } - private func isInsideFencedCodeBlock( - _ lineNumber: Int, + private func fencedCodeBlockContext( + containing lineNumber: Int, in lineIndex: DocumentLineIndex, activeLineIndex: Int - ) -> Bool { - guard lineNumber != activeLineIndex, lineNumber > 0 else { return false } + ) -> CodeBlockContext? { + guard lineNumber != activeLineIndex, lineNumber > 0 else { return nil } var isInside = false + var language: String? var lineCursor = 0 while lineCursor < lineNumber { if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex), - case .fencedCodeFence = lineKind(for: line) { - isInside.toggle() + case .fencedCodeFence(_, let languageRange) = lineKind(for: line) { + if isInside { + isInside = false + language = nil + } else { + isInside = true + language = self.language(in: line, range: languageRange) + } } lineCursor += 1 } - return isInside + return isInside ? CodeBlockContext(language: language) : nil } + + private func language(in line: EditorLine, range: NSRange?) -> String? { + guard let range else { return nil } + let localRange = NSRange(location: range.location - line.range.location, length: range.length) + let language = (line.source as NSString).substring(with: localRange) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return language.isEmpty ? nil : language + } +} + +private struct CodeBlockContext { + var language: String? } private struct InlineMatch { diff --git a/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift index 2bd7582..2f324b8 100644 --- a/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift +++ b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift @@ -123,6 +123,41 @@ final class DocumentPresentationStateTests: XCTestCase { XCTAssertEqual(presentation.lines.count, 1) XCTAssertEqual(presentation.lines[0].state, .rendered) - XCTAssertEqual(presentation.lines[0].renderPlan.kind, .codeBlockContent) + XCTAssertEqual(presentation.lines[0].renderPlan.kind, .codeBlockContent(language: "swift")) + } + + func testEditableRegionMakesMultipleSelectedLinesSource() { + let source = "# Heading\nParagraph\n* [ ] Task\nOutro" + let lineIndex = DocumentLineIndex(source: source) + let selectionStart = (source as NSString).range(of: "Paragraph").location + let selectionEnd = (source as NSString).range(of: "Task").upperBound + let region = EditableRegion.selection( + NSRange(location: selectionStart, length: selectionEnd - selectionStart), + in: lineIndex + ) + + let presentation = DocumentPresentationState(lineIndex: lineIndex, editableRegion: region) + + XCTAssertEqual(presentation.lines.filter { $0.state == .source }.map(\.line.index), [1, 2]) + XCTAssertEqual(presentation.lines.filter { $0.state == .rendered }.map(\.line.index), [0, 3]) + } + + func testEditableRegionExpandsToEntireCodeBlock() { + let source = "Intro\n```swift\nlet value = 42\n```\nOutro" + let lineIndex = DocumentLineIndex(source: source) + let cursorLocation = (source as NSString).range(of: "value").location + + let region = EditableRegion.selection(NSRange(location: cursorLocation, length: 0), in: lineIndex) + let presentation = DocumentPresentationState(lineIndex: lineIndex, editableRegion: region) + + XCTAssertEqual(region.lineIndexes, [1, 2, 3]) + XCTAssertEqual(presentation.lines.filter { $0.state == .source }.map(\.line.index), [1, 2, 3]) + XCTAssertEqual(presentation.lines.filter { $0.state == .rendered }.map(\.line.index), [0, 4]) + } +} + +private extension NSRange { + var upperBound: Int { + location + length } } diff --git a/Tests/SaplingEditorTests/EditorStateTests.swift b/Tests/SaplingEditorTests/EditorStateTests.swift index c153cf3..32625c3 100644 --- a/Tests/SaplingEditorTests/EditorStateTests.swift +++ b/Tests/SaplingEditorTests/EditorStateTests.swift @@ -215,7 +215,7 @@ final class EditorStateTests: XCTestCase { languageRange: NSRange(location: 3, length: 5) ) ) - XCTAssertEqual(plans[1].kind, .codeBlockContent) + XCTAssertEqual(plans[1].kind, .codeBlockContent(language: "swift")) XCTAssertEqual(plans[2].kind, .fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil)) } diff --git a/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift index 71b5fcd..d3aacbc 100644 --- a/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift +++ b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift @@ -95,5 +95,49 @@ final class HybridMarkdownLiveEditorHarnessTests: XCTestCase { ) } } + + func testLiveMultiLineSelectionUsesEditableRegion() throws { + let source = "# Heading\nParagraph\n* [ ] Task\nOutro" + let nsSource = source as NSString + let selectionStart = nsSource.range(of: "#").location + let selectionEnd = nsSource.range(of: "Task").upperBound + let taskLineIndex = 2 + let harness = HybridMarkdownLiveEditorHarness(source: source) + + harness.simulateLaunchFirstResponder() + harness.setSelection(NSRange(location: selectionStart, length: selectionEnd - selectionStart)) + + XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "#").location)) + XCTAssertNil(harness.checklistButtonFrame(lineIndex: taskLineIndex)) + + harness.setSelection(NSRange(location: nsSource.range(of: "Outro").location, length: 0)) + + XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "#").location)) + XCTAssertNotNil(harness.checklistButtonFrame(lineIndex: taskLineIndex)) + } + + func testLiveCodeBlockSelectionUsesWholeBlockEditableRegion() { + let source = "Intro\n```swift\nlet value = 42\n```" + let nsSource = source as NSString + let harness = HybridMarkdownLiveEditorHarness(source: source) + let cursorLocation = nsSource.range(of: "value").location + + harness.simulateLaunchFirstResponder() + harness.setSelection(NSRange(location: cursorLocation, length: 0)) + + XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "```swift").location)) + XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location)) + + harness.setSelection(NSRange(location: nsSource.range(of: "Intro").location, length: 0)) + + XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```swift").location)) + XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location)) + } } #endif + +private extension NSRange { + var upperBound: Int { + location + length + } +} diff --git a/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift b/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift index 80acbca..4d712a9 100644 --- a/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift +++ b/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift @@ -47,18 +47,21 @@ final class MarkdownTextStylerRenderingTests: XCTestCase { } func testRenderedCodeBlockHidesFencesAndStylesCodeContent() { - let source = "Intro\n```swift\nlet value = 42\n```" + let source = "Intro\n```swift\nstruct Example {\n let value = 42\n}\n```" let storage = styledStorage(source: source, activeLineIndex: 0) let nsSource = source as NSString let openingFence = nsSource.range(of: "```swift") let language = nsSource.range(of: "swift") let code = nsSource.range(of: "let value = 42") + let keyword = nsSource.range(of: "struct") let closingFence = nsSource.range(of: "```", options: .backwards) XCTAssertTrue(isHidden(storage, at: openingFence.location)) XCTAssertFalse(isHidden(storage, at: language.location)) XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil)) XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true) + XCTAssertGreaterThan(paragraphStyle(in: storage, at: code.location).headIndent, 0) + XCTAssertEqual(font(in: storage, at: keyword.location).fontDescriptor.symbolicTraits.contains(.bold), true) XCTAssertTrue(isHidden(storage, at: closingFence.location)) } @@ -89,6 +92,16 @@ final class MarkdownTextStylerRenderingTests: XCTestCase { XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true) } + func testRenderedJsonYamlAndMarkdownCodeHighlighting() { + let json = styledStorage(source: "```json\n{\"name\": \"Sapling\", \"enabled\": true}\n```", activeLineIndex: -1) + let yaml = styledStorage(source: "```yaml\nname: Sapling\nactive: true\n```", activeLineIndex: -1) + let markdown = styledStorage(source: "```markdown\n# Heading\nSee **bold** text\n```", activeLineIndex: -1) + + XCTAssertEqual(font(in: json, at: ("```json\n{" as NSString).length).fontDescriptor.symbolicTraits.contains(.bold), true) + XCTAssertEqual(font(in: yaml, at: ("```yaml\n" as NSString).length).fontDescriptor.symbolicTraits.contains(.bold), true) + XCTAssertEqual(font(in: markdown, at: ("```markdown\n" as NSString).length).fontDescriptor.symbolicTraits.contains(.bold), true) + } + private func styledStorage(source: String, activeLineIndex: Int) -> NSTextStorage { let storage = NSTextStorage(string: source) let lineIndex = DocumentLineIndex(source: source) @@ -123,5 +136,9 @@ final class MarkdownTextStylerRenderingTests: XCTestCase { } return font } + + private func paragraphStyle(in storage: NSTextStorage, at location: Int) -> NSParagraphStyle { + storage.attribute(.paragraphStyle, at: location, effectiveRange: nil) as? NSParagraphStyle ?? NSParagraphStyle() + } } #endif