From d12a0a58ed13040cec0c360219b84b12dff0bacf Mon Sep 17 00:00:00 2001 From: Feror Date: Mon, 1 Jun 2026 14:22:27 +0200 Subject: [PATCH] feat(renderer): add code block containers --- Docs/editor-investigation.md | 63 ++++- .../DocumentPresentationState.swift | 63 ++++- .../SaplingEditor/HybridMarkdownEditor.swift | 220 ++++++++++++++++-- .../HybridMarkdownLineRenderer.swift | 44 +++- .../DocumentPresentationStateTests.swift | 1 + .../SaplingEditorTests/EditorStateTests.swift | 8 +- ...HybridMarkdownLiveEditorHarnessTests.swift | 36 +++ .../MarkdownTextStylerRenderingTests.swift | 6 +- 8 files changed, 392 insertions(+), 49 deletions(-) diff --git a/Docs/editor-investigation.md b/Docs/editor-investigation.md index 25efb01..8971ed6 100644 --- a/Docs/editor-investigation.md +++ b/Docs/editor-investigation.md @@ -1461,9 +1461,9 @@ Code blocks now behave as block-level editing units and have a stronger rendered Rendered behavior: - Opening and closing fences are hidden in rendered mode. -- The fence language remains visible as a compact label when present. +- The fence language is available to the presentation layer as a compact label when present. - Code content uses a monospaced font. -- Code block paragraphs receive a tinted background, indentation, and tighter spacing. +- Code block paragraphs receive indentation and tighter spacing; the unified background is drawn by the live text view container layer. - Code content receives modest syntax highlighting. Editable behavior: @@ -1488,6 +1488,65 @@ Syntax highlighting scope: 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. +## Finding #24 — Code Block Containers + +Milestone 3.7 upgrades rendered code blocks from styled lines into first-class presentation containers. The semantic model remains `RenderedCodeBlockElement`, but the live AppKit materialization now creates `CodeBlockContainerPresentation` values and lets `EditorTextView` draw one rounded block behind the code glyphs. + +Previous implementation: + +- Opening fences hid only the fence marker while the language remained styled in the text line. +- Code content used per-line background attributes. +- Closing fences were hidden, but the rendered block still read visually as separate styled paragraphs. + +New presentation pipeline: + +```mermaid +flowchart LR + Markdown["Markdown source"] --> Lines["DocumentLineIndex"] + Lines --> State["DocumentPresentationState"] + State --> Block["RenderedCodeBlockElement"] + Block --> Container["CodeBlockContainerPresentation"] + Container --> TextView["EditorTextView.drawBackground(in:)"] + TextView --> Visual["Rounded container + header + highlighted glyphs"] +``` + +Rendered behavior: + +- Opening and closing fences are hidden, including the raw language token. +- The language appears in a dedicated header bar. +- Code content keeps monospaced text and lightweight syntax highlighting. +- Background is drawn once for the whole block rather than once per line. +- The container has rounded corners, a separator between header and body, and horizontal padding via paragraph indentation. + +Editing lifecycle: + +```mermaid +stateDiagram-v2 + [*] --> RenderedContainer + RenderedContainer --> SourceBlock: EditableRegion intersects any code block line + SourceBlock --> RenderedContainer: EditableRegion leaves the block +``` + +`DocumentPresentationState.renderedCodeBlocks(in:editableRegion:)` filters out blocks touched by the editable region, so source mode never draws a container behind visible fences. This keeps the existing Milestone 3.6 code-block editing contract intact: entering any line inside the fenced block makes the entire block editable source; leaving the block restores the rendered container. + +Performance impact: + +- Syntax highlighting remains line-local. +- The container model is rebuilt only during the same styling passes that already materialize presentation state. +- Documents without fenced code blocks return immediately from rendered-code-block discovery. +- Drawing uses TextKit's existing line fragment geometry and does not mutate text storage. + +Regression coverage: + +- `testLiveRenderedCodeBlockUsesSingleContainerPresentation` exercises the real `NSTextView` harness and verifies one rendered container, a language header, hidden fences, source-mode removal when the cursor enters the block, and restoration when the cursor leaves. +- `testRenderedCodeBlockHidesFencesAndStylesCodeContent` verifies that text storage no longer relies on per-line background attributes for code block presentation. + +Future container affordances: + +- Copy buttons and collapse buttons fit naturally as overlay controls anchored to `CodeBlockContainerPresentation` frames. +- Line numbers are feasible as a body gutter drawn by `EditorTextView`, but should be deferred until wrapping and selection behavior are validated. +- These affordances do not require changing the Markdown source model; they are presentation-layer additions. + ## 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 1780fe3..4f53764 100644 --- a/Sources/SaplingEditor/DocumentPresentationState.swift +++ b/Sources/SaplingEditor/DocumentPresentationState.swift @@ -144,8 +144,8 @@ public struct DocumentRenderModel: Hashable, Sendable { return "orderedList:\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)" case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel): 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 .fencedCodeFence(let markerRange, let languageRange, let role): + return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil"):\(role.rawValue)" case .codeBlockContent(let language): return "codeContent:\(language ?? "plain")" case .tableRow(let cellRanges, let separatorRanges, let isDivider): @@ -253,7 +253,10 @@ public struct DocumentPresentationState: Hashable, Sendable { } if lineIndexes == nil { - collectedElements.append(contentsOf: Self.codeBlockElements(from: renderPlans)) + collectedElements.append(contentsOf: Self.codeBlockElements( + from: renderPlans, + editableRegion: editableRegion + )) } self.lines = presentationLines @@ -295,6 +298,20 @@ public struct DocumentPresentationState: Hashable, Sendable { ).renderedTasks } + public static func renderedCodeBlocks( + in lineIndex: DocumentLineIndex, + editableRegion: EditableRegion + ) -> [RenderedCodeBlockElement] { + guard lineIndex.source.contains("```") || lineIndex.source.contains("~~~") else { + return [] + } + + return DocumentPresentationState( + lineIndex: lineIndex, + editableRegion: editableRegion + ).renderedCodeBlocks + } + fileprivate static func renderPlans( for lines: [EditorLine], lineIndex: DocumentLineIndex, @@ -404,30 +421,35 @@ public struct DocumentPresentationState: Hashable, Sendable { return elements } - fileprivate static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] { + fileprivate static func codeBlockElements( + from plans: [HybridMarkdownLineRenderPlan], + editableRegion: EditableRegion = .none() + ) -> [RenderedDocumentElement] { var elements: [RenderedDocumentElement] = [] var openLineIndexes: [Int] = [] var openRange: NSRange? var openLanguageRange: NSRange? for plan in plans { - guard case .fencedCodeFence(let markerRange, let languageRange) = plan.kind else { + guard case .fencedCodeFence(let markerRange, let languageRange, let role) = plan.kind else { if openRange != nil { openLineIndexes.append(plan.line.index) } continue } - if let sourceRange = openRange { + if role == .closing, let sourceRange = openRange { openLineIndexes.append(plan.line.index) - elements.append(.codeBlock(RenderedCodeBlockElement( + appendCodeBlockElement( + to: &elements, lineIndexes: openLineIndexes, sourceRange: NSRange( location: sourceRange.location, length: plan.line.range.upperBound - sourceRange.location ), - languageRange: openLanguageRange - ))) + languageRange: openLanguageRange, + editableRegion: editableRegion + ) openLineIndexes.removeAll() openRange = nil openLanguageRange = nil @@ -440,18 +462,35 @@ public struct DocumentPresentationState: Hashable, Sendable { if let sourceRange = openRange, let lastLine = plans.last?.line { - elements.append(.codeBlock(RenderedCodeBlockElement( + appendCodeBlockElement( + to: &elements, lineIndexes: openLineIndexes, sourceRange: NSRange( location: sourceRange.location, length: lastLine.range.upperBound - sourceRange.location ), - languageRange: openLanguageRange - ))) + languageRange: openLanguageRange, + editableRegion: editableRegion + ) } return elements } + + private static func appendCodeBlockElement( + to elements: inout [RenderedDocumentElement], + lineIndexes: [Int], + sourceRange: NSRange, + languageRange: NSRange?, + editableRegion: EditableRegion + ) { + guard !lineIndexes.contains(where: editableRegion.contains) else { return } + elements.append(.codeBlock(RenderedCodeBlockElement( + lineIndexes: lineIndexes, + sourceRange: sourceRange, + languageRange: languageRange + ))) + } } private extension NSRange { diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index fcb1daa..d01505e 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -233,6 +233,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { scrollView.editorTextView = textView scrollView.onEditorLayoutInvalidated = { [weak coordinator = context.coordinator] textView in coordinator?.syncChecklistControlFrames(in: textView) + (textView as? EditorTextView)?.invalidateCodeBlockContainers() } scrollView.updateEditorInsets() context.coordinator.applyHybridAttributes(to: textView) @@ -357,6 +358,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { invalidationPlan: invalidationPlan, activeLineIndex: activeLineIndex ) + syncCodeBlockContainers(in: textView, editableRegion: editableRegion) parent.onRenderPass(EditorRenderPassMetric( reason: invalidationPlan.reason, durationMilliseconds: Date().timeIntervalSince(start) * 1000, @@ -483,6 +485,34 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } } + private func syncCodeBlockContainers(in textView: NSTextView, editableRegion: EditableRegion) { + guard let textView = textView as? EditorTextView else { return } + let codeBlocks = DocumentPresentationState.renderedCodeBlocks( + in: currentLineIndex, + editableRegion: editableRegion + ) + textView.codeBlockContainers = codeBlocks.map { + CodeBlockContainerPresentation( + codeBlock: $0, + languageLabel: codeBlockLanguageLabel(for: $0, in: textView.string) + ) + } + } + + private func codeBlockLanguageLabel( + for codeBlock: RenderedCodeBlockElement, + in source: String + ) -> String { + guard let languageRange = codeBlock.languageRange, + languageRange.upperBound <= source.utf16.count + else { return "Text" } + + let language = (source as NSString).substring(with: languageRange) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !language.isEmpty else { return "Text" } + return language.prefix(1).uppercased() + language.dropFirst().lowercased() + } + private func toggleTask(_ task: RenderedTaskElement, in textView: NSTextView) { guard task.checkboxRange.upperBound <= textView.string.utf16.count else { return } @@ -565,9 +595,20 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } } +private struct CodeBlockContainerPresentation: Equatable { + var codeBlock: RenderedCodeBlockElement + var languageLabel: String +} + private final class EditorTextView: NSTextView { var onFocusStateChange: ((NSTextView) -> Void)? var onUserEditingInteraction: ((NSTextView) -> Void)? + var codeBlockContainers: [CodeBlockContainerPresentation] = [] { + didSet { + guard oldValue != codeBlockContainers else { return } + needsDisplay = true + } + } override var acceptsFirstResponder: Bool { true @@ -603,6 +644,115 @@ private final class EditorTextView: NSTextView { onUserEditingInteraction?(self) super.paste(sender) } + + func invalidateCodeBlockContainers() { + needsDisplay = true + } + + func codeBlockContainerFrame(containing lineIndex: Int) -> NSRect? { + guard let container = codeBlockContainers.first(where: { $0.codeBlock.lineIndexes.contains(lineIndex) }) else { + return nil + } + return codeBlockFrame(for: container) + } + + override func drawBackground(in rect: NSRect) { + super.drawBackground(in: rect) + drawCodeBlockContainers(in: rect) + } + + private func drawCodeBlockContainers(in dirtyRect: NSRect) { + for container in codeBlockContainers { + guard let frame = codeBlockFrame(for: container), + frame.intersects(dirtyRect) + else { continue } + + drawCodeBlockContainer(container, in: frame) + } + } + + private func drawCodeBlockContainer( + _ container: CodeBlockContainerPresentation, + in frame: NSRect + ) { + let cornerRadius: CGFloat = 8 + let headerHeight = min(30, max(24, frame.height * 0.32)) + let headerRect = NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: headerHeight) + + let bodyPath = NSBezierPath(roundedRect: frame, xRadius: cornerRadius, yRadius: cornerRadius) + NSColor.controlAccentColor.withAlphaComponent(0.075).setFill() + bodyPath.fill() + + NSGraphicsContext.saveGraphicsState() + bodyPath.addClip() + NSColor.controlAccentColor.withAlphaComponent(0.12).setFill() + headerRect.fill() + NSColor.separatorColor.withAlphaComponent(0.35).setStroke() + NSBezierPath.strokeLine( + from: NSPoint(x: headerRect.minX, y: headerRect.maxY), + to: NSPoint(x: headerRect.maxX, y: headerRect.maxY) + ) + NSGraphicsContext.restoreGraphicsState() + + NSColor.separatorColor.withAlphaComponent(0.35).setStroke() + bodyPath.lineWidth = 1 + bodyPath.stroke() + + let labelAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold), + .foregroundColor: NSColor.secondaryLabelColor + ] + let label = NSAttributedString(string: container.languageLabel, attributes: labelAttributes) + let labelRect = NSRect( + x: headerRect.minX + 14, + y: headerRect.midY - ceil(label.size().height / 2), + width: max(0, headerRect.width - 28), + height: label.size().height + ) + label.draw(in: labelRect) + } + + private func codeBlockFrame(for container: CodeBlockContainerPresentation) -> NSRect? { + guard let layoutManager, + let textContainer, + textStorage?.length ?? 0 > 0 + else { return nil } + + let textLength = string.utf16.count + let sourceRange = NSRange( + location: min(container.codeBlock.sourceRange.location, max(0, textLength - 1)), + length: min( + container.codeBlock.sourceRange.length, + max(0, textLength - container.codeBlock.sourceRange.location) + ) + ) + guard sourceRange.location < textLength else { return nil } + + layoutManager.ensureLayout(forCharacterRange: sourceRange) + let firstCharacterRange = NSRange(location: sourceRange.location, length: 1) + let lastLocation = max(sourceRange.location, min(sourceRange.upperBound - 1, textLength - 1)) + let lastCharacterRange = NSRange(location: lastLocation, length: 1) + let firstGlyphRange = layoutManager.glyphRange( + forCharacterRange: firstCharacterRange, + actualCharacterRange: nil + ) + let lastGlyphRange = layoutManager.glyphRange( + forCharacterRange: lastCharacterRange, + actualCharacterRange: nil + ) + guard firstGlyphRange.length > 0, lastGlyphRange.length > 0 else { return nil } + + let firstFragment = layoutManager.lineFragmentRect(forGlyphAt: firstGlyphRange.location, effectiveRange: nil) + let lastFragment = layoutManager.lineFragmentRect(forGlyphAt: lastGlyphRange.location, effectiveRange: nil) + let origin = textContainerOrigin + let horizontalInset: CGFloat = 4 + return NSRect( + x: origin.x + horizontalInset, + y: origin.y + firstFragment.minY, + width: max(0, textContainer.containerSize.width - horizontalInset * 2), + height: max(0, lastFragment.maxY - firstFragment.minY) + ) + } } private final class ChecklistOverlayButton: NSButton { @@ -774,6 +924,7 @@ public final class HybridMarkdownLiveEditorHarness { self.scrollView.editorTextView = textView self.scrollView.onEditorLayoutInvalidated = { [weak coordinator] textView in coordinator?.syncChecklistControlFrames(in: textView) + (textView as? EditorTextView)?.invalidateCodeBlockContainers() } self.scrollView.updateEditorInsets() @@ -878,6 +1029,18 @@ public final class HybridMarkdownLiveEditorHarness { return labelFrame.minX - buttonFrame.maxX } + public func codeBlockContainerCount() -> Int { + textView.codeBlockContainers.count + } + + public func codeBlockContainerLabel(containing lineIndex: Int) -> String? { + textView.codeBlockContainers.first { $0.codeBlock.lineIndexes.contains(lineIndex) }?.languageLabel + } + + public func codeBlockContainerFrame(containing lineIndex: Int) -> CGRect? { + textView.codeBlockContainerFrame(containing: lineIndex) + } + public func selectedRange() -> NSRange { textView.selectedRange() } @@ -1135,6 +1298,7 @@ struct MarkdownTextStylingResult { var styledLineCount: Int var styledLineIndexes: [Int] var renderedTasks: [RenderedTaskElement] + var renderedCodeBlocks: [RenderedCodeBlockElement] var editableRegion: EditableRegion static let empty = MarkdownTextStylingResult( @@ -1142,6 +1306,7 @@ struct MarkdownTextStylingResult { styledLineCount: 0, styledLineIndexes: [], renderedTasks: [], + renderedCodeBlocks: [], editableRegion: .none() ) } @@ -1176,6 +1341,7 @@ enum MarkdownTextStyler { styledLineCount: lineIndex.lineCount, styledLineIndexes: Array(0.. NSMutableParagraphStyle { + private static func codeBlockContentParagraphStyle() -> NSMutableParagraphStyle { let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 3 - paragraph.paragraphSpacing = 4 - paragraph.paragraphSpacingBefore = 2 - paragraph.firstLineHeadIndent = 14 - paragraph.headIndent = 14 + paragraph.paragraphSpacing = 0 + paragraph.paragraphSpacingBefore = 0 + paragraph.firstLineHeadIndent = 18 + paragraph.headIndent = 18 return paragraph } - private static func codeBlockAttributes(accentColor: PlatformColor) -> [NSAttributedString.Key: Any] { + private static func codeBlockFenceParagraphStyle(role: FencedCodeFenceRole) -> NSMutableParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 0 + paragraph.paragraphSpacing = role == .closing ? 10 : 0 + paragraph.paragraphSpacingBefore = role == .opening ? 8 : 0 + paragraph.minimumLineHeight = role == .opening ? 30 : 12 + paragraph.maximumLineHeight = role == .opening ? 30 : 12 + paragraph.firstLineHeadIndent = 18 + paragraph.headIndent = 18 + return paragraph + } + + private static func codeBlockContentAttributes() -> [NSAttributedString.Key: Any] { [ .font: monospacedFont(size: 15, weight: .regular), - .backgroundColor: accentColor.withAlphaComponent(0.08), - .paragraphStyle: codeBlockParagraphStyle() + .paragraphStyle: codeBlockContentParagraphStyle() + ] + } + + private static func codeBlockFenceAttributes(role: FencedCodeFenceRole) -> [NSAttributedString.Key: Any] { + [ + .font: monospacedFont(size: 15, weight: .regular), + .paragraphStyle: codeBlockFenceParagraphStyle(role: role) ] } diff --git a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift index 8048184..56671d5 100644 --- a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift +++ b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift @@ -14,11 +14,16 @@ public enum HybridMarkdownLineKind: Hashable, Sendable { checked: Bool, nestingLevel: Int ) - case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?) + case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?, role: FencedCodeFenceRole) case codeBlockContent(language: String?) case tableRow(cellRanges: [NSRange], separatorRanges: [NSRange], isDivider: Bool) } +public enum FencedCodeFenceRole: String, Hashable, Sendable { + case opening + case closing +} + public enum HybridMarkdownSpanKind: Hashable, Sendable { case bold case italic @@ -68,12 +73,13 @@ public struct HybridMarkdownLineRenderer: Sendable { let nonCodeKind = lineKind(for: line) let kind: HybridMarkdownLineKind - if case .fencedCodeFence(_, let languageRange) = nonCodeKind { - kind = nonCodeKind + if case .fencedCodeFence(let markerRange, let languageRange, _) = nonCodeKind { if isInCodeBlock { + kind = .fencedCodeFence(markerRange: markerRange, languageRange: languageRange, role: .closing) isInCodeBlock = false codeBlockLanguage = nil } else { + kind = .fencedCodeFence(markerRange: markerRange, languageRange: languageRange, role: .opening) isInCodeBlock = true codeBlockLanguage = language(in: line, range: languageRange) } @@ -102,8 +108,12 @@ public struct HybridMarkdownLineRenderer: Sendable { let nonCodeKind = lineKind(for: line) let kind: HybridMarkdownLineKind - if case .fencedCodeFence = nonCodeKind { - kind = nonCodeKind + if case .fencedCodeFence(let markerRange, let languageRange, _) = nonCodeKind { + kind = .fencedCodeFence( + markerRange: markerRange, + languageRange: languageRange, + role: fencedCodeFenceRole(for: line.index, in: lineIndex, activeLineIndex: activeLineIndex) + ) } else if let context = fencedCodeBlockContext( containing: line.index, in: lineIndex, @@ -277,7 +287,8 @@ public struct HybridMarkdownLineRenderer: Sendable { location: line.range.location + match.range(at: 2).location, length: match.range(at: 2).length ), - languageRange: languageRange + languageRange: languageRange, + role: .opening ) } @@ -529,7 +540,7 @@ public struct HybridMarkdownLineRenderer: Sendable { var lineCursor = 0 while lineCursor < lineNumber { if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex), - case .fencedCodeFence(_, let languageRange) = lineKind(for: line) { + case .fencedCodeFence(_, let languageRange, _) = lineKind(for: line) { if isInside { isInside = false language = nil @@ -543,6 +554,25 @@ public struct HybridMarkdownLineRenderer: Sendable { return isInside ? CodeBlockContext(language: language) : nil } + private func fencedCodeFenceRole( + for lineNumber: Int, + in lineIndex: DocumentLineIndex, + activeLineIndex: Int + ) -> FencedCodeFenceRole { + guard lineNumber > 0 else { return .opening } + + var isInside = false + var lineCursor = 0 + while lineCursor < lineNumber { + if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex), + case .fencedCodeFence = lineKind(for: line) { + isInside.toggle() + } + lineCursor += 1 + } + return isInside ? .closing : .opening + } + 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) diff --git a/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift index 2f324b8..3bd2fe5 100644 --- a/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift +++ b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift @@ -153,6 +153,7 @@ final class DocumentPresentationStateTests: XCTestCase { 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]) + XCTAssertTrue(presentation.renderedCodeBlocks.isEmpty) } } diff --git a/Tests/SaplingEditorTests/EditorStateTests.swift b/Tests/SaplingEditorTests/EditorStateTests.swift index 32625c3..25cd2ef 100644 --- a/Tests/SaplingEditorTests/EditorStateTests.swift +++ b/Tests/SaplingEditorTests/EditorStateTests.swift @@ -212,11 +212,15 @@ final class EditorStateTests: XCTestCase { plans[0].kind, .fencedCodeFence( markerRange: NSRange(location: 0, length: 3), - languageRange: NSRange(location: 3, length: 5) + languageRange: NSRange(location: 3, length: 5), + role: .opening ) ) XCTAssertEqual(plans[1].kind, .codeBlockContent(language: "swift")) - XCTAssertEqual(plans[2].kind, .fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil)) + XCTAssertEqual( + plans[2].kind, + .fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil, role: .closing) + ) } func testHybridRendererSupportsMarkdownTables() { diff --git a/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift index d3aacbc..d7c2e41 100644 --- a/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift +++ b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift @@ -133,6 +133,42 @@ final class HybridMarkdownLiveEditorHarnessTests: XCTestCase { XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```swift").location)) XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location)) } + + func testLiveRenderedCodeBlockUsesSingleContainerPresentation() throws { + let source = """ + Intro + ```swift + struct Example { + let value = 42 + } + ``` + Outro + """ + let nsSource = source as NSString + let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700) + + harness.simulateLaunchFirstResponder() + + XCTAssertEqual(harness.codeBlockContainerCount(), 1) + XCTAssertEqual(harness.codeBlockContainerLabel(containing: 2), "Swift") + let initialFrame = try XCTUnwrap(harness.codeBlockContainerFrame(containing: 2)) + XCTAssertGreaterThan(initialFrame.height, 80) + XCTAssertGreaterThan(initialFrame.width, 500) + XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```swift").location)) + XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location)) + + harness.setSelection(NSRange(location: nsSource.range(of: "value").location, length: 0)) + + XCTAssertEqual(harness.codeBlockContainerCount(), 0) + XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "```swift").location)) + + harness.setSelection(NSRange(location: nsSource.range(of: "Intro").location, length: 0)) + + XCTAssertEqual(harness.codeBlockContainerCount(), 1) + let restoredFrame = try XCTUnwrap(harness.codeBlockContainerFrame(containing: 2)) + XCTAssertEqual(initialFrame.origin.x, restoredFrame.origin.x, accuracy: 0.001) + XCTAssertEqual(initialFrame.size.width, restoredFrame.size.width, accuracy: 0.001) + } } #endif diff --git a/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift b/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift index 4d712a9..f592f66 100644 --- a/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift +++ b/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift @@ -57,8 +57,8 @@ final class MarkdownTextStylerRenderingTests: XCTestCase { 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)) + XCTAssertTrue(isHidden(storage, at: language.location)) + XCTAssertNil(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) @@ -88,7 +88,7 @@ final class MarkdownTextStylerRenderingTests: XCTestCase { ) let code = (source as NSString).range(of: "let value = 42") - XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil)) + XCTAssertNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil)) XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true) }