feat(renderer): add code block containers
This commit is contained in:
parent
3da06e096b
commit
d12a0a58ed
8 changed files with 392 additions and 49 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue