feat(renderer): add code block containers

This commit is contained in:
Feror 2026-06-01 14:22:27 +02:00
parent 3da06e096b
commit d12a0a58ed
8 changed files with 392 additions and 49 deletions

View file

@ -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.

View file

@ -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 {

View file

@ -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..<lineIndex.lineCount),
renderedTasks: [],
renderedCodeBlocks: [],
editableRegion: resolvedEditableRegion
)
}
@ -1227,6 +1393,7 @@ enum MarkdownTextStyler {
styledLineCount: styledLineCount,
styledLineIndexes: styledLineIndexes,
renderedTasks: presentationState.renderedTasks,
renderedCodeBlocks: presentationState.renderedCodeBlocks,
editableRegion: presentationState.editableRegion
)
}
@ -1338,22 +1505,11 @@ enum MarkdownTextStyler {
.foregroundColor: secondaryTextColor
], range: contentRange)
}
case .fencedCodeFence(let markerRange, let languageRange):
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
if let languageRange {
hideSyntax(
in: textStorage,
range: NSRange(location: markerRange.location, length: languageRange.location - markerRange.location)
)
textStorage.addAttributes([
.foregroundColor: accentColor,
.font: monospacedFont(size: 13, weight: .semibold)
], range: languageRange)
} else {
hideSyntax(in: textStorage, range: line.range)
}
case .fencedCodeFence(_, _, let role):
textStorage.addAttributes(codeBlockFenceAttributes(role: role), range: paragraphRange)
hideSyntax(in: textStorage, range: line.range)
case .codeBlockContent(let language):
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
textStorage.addAttributes(codeBlockContentAttributes(), range: paragraphRange)
styleCodeSyntax(
in: textStorage,
line: line,
@ -1599,21 +1755,39 @@ enum MarkdownTextStyler {
return paragraph
}
private static func codeBlockParagraphStyle() -> 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)
]
}

View file

@ -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)

View file

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

View file

@ -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() {

View file

@ -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

View file

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