feat(editor): support editable regions and code blocks

This commit is contained in:
Feror 2026-06-01 10:17:17 +02:00
parent 3e7fe6ef03
commit 941fcd5d56
10 changed files with 585 additions and 49 deletions

View file

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

View file

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

View file

@ -0,0 +1,125 @@
import Foundation
public struct EditableRegion: Hashable, Sendable {
public var lineIndexes: [Int]
public init(lineIndexes: some Sequence<Int>) {
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<Int>? {
guard (0..<lineCount).contains(lineIndex) else { return nil }
var openLineIndex: Int?
var openFence: String?
for currentLineIndex in 0..<lineCount {
guard let line = editorLine(at: currentLineIndex, activeLineIndex: -1),
let fence = Self.fenceMarker(in: line.source)
else { continue }
if let start = openLineIndex {
if fence == openFence {
let blockRange = start...currentLineIndex
return blockRange.contains(lineIndex) ? blockRange : nil
}
} else {
openLineIndex = currentLineIndex
openFence = fence
}
}
if let start = openLineIndex {
let blockRange = start...max(start, lineCount - 1)
return blockRange.contains(lineIndex) ? blockRange : nil
}
return nil
}
static func fenceMarker(in source: String) -> 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
}
}

View file

@ -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..<lineCount).contains($0) }
.sorted(),
changedRange: changedRange
)
}
}
private extension NSRange {
var upperBound: Int {
location + length

View file

@ -266,6 +266,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var lastStyledEditableRegion: EditableRegion?
private var pendingEdit: DocumentLineIndexEdit?
private var hasUserActivatedEditing = false
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
@ -316,8 +317,9 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
func applyHybridAttributes(to textView: NSTextView) {
guard let textStorage = textView.textStorage else { return }
let activeLineIndex = presentationActiveLineIndex(in: textView)
let invalidationPlan = invalidationPlan(for: textView.string, activeLineIndex: activeLineIndex)
let editableRegion = presentationEditableRegion(in: textView)
let activeLineIndex = editableRegion.primaryLineIndex
let invalidationPlan = invalidationPlan(for: textView.string, editableRegion: editableRegion)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange()
@ -336,7 +338,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor,
usesRenderedControls: true
usesRenderedControls: true,
editableRegion: editableRegion
)
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
@ -347,6 +350,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
lastStyledText = textView.string
lastStyledActiveLineIndex = activeLineIndex
lastStyledEditableRegion = editableRegion
syncChecklistControls(
in: textView,
stylingResult: stylingResult,
@ -365,11 +369,11 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
))
}
private func presentationActiveLineIndex(in textView: NSTextView) -> 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..<lineIndex.lineCount),
renderedTasks: []
renderedTasks: [],
editableRegion: resolvedEditableRegion
)
}
@ -1154,7 +1186,7 @@ enum MarkdownTextStyler {
let presentationState = DocumentPresentationState(
lineIndex: lineIndex,
activeLineIndex: activeLineIndex,
editableRegion: resolvedEditableRegion,
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
)
var styledLineCount = 0
@ -1193,7 +1225,8 @@ enum MarkdownTextStyler {
totalLineCount: presentationState.lineCount,
styledLineCount: styledLineCount,
styledLineIndexes: styledLineIndexes,
renderedTasks: presentationState.renderedTasks
renderedTasks: presentationState.renderedTasks,
editableRegion: presentationState.editableRegion
)
}
@ -1318,8 +1351,16 @@ enum MarkdownTextStyler {
} else {
hideSyntax(in: textStorage, range: line.range)
}
case .codeBlockContent:
case .codeBlockContent(let language):
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
styleCodeSyntax(
in: textStorage,
line: line,
language: language,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
case .tableRow(_, let separatorRanges, let isDivider):
textStorage.addAttributes([
.font: monospacedFont(size: 15, weight: .regular),
@ -1375,6 +1416,90 @@ enum MarkdownTextStyler {
}
}
private static func styleCodeSyntax(
in textStorage: NSTextStorage,
line: EditorLine,
language: String?,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
let normalizedLanguage = normalizedCodeLanguage(language)
guard normalizedLanguage != "text" else { return }
switch normalizedLanguage {
case "swift":
applyCodeRegex("\\b(import|struct|class|enum|protocol|extension|func|let|var|if|else|guard|return|for|while|switch|case|default|in|try|throw|throws|async|await|public|private|internal|static|final|init|self|nil|true|false)\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex("\"(?:\\\\.|[^\"\\\\])*\"", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeStringColor(accentColor: accentColor)], range: match.range)
}
applyCodeRegex("\\b\\d+(?:\\.\\d+)?\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
}
applyCodeRegex("//.*$", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
case "json":
applyCodeRegex("\"(?:\\\\.|[^\"\\\\])*\"\\s*:", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex(":\\s*(\"(?:\\\\.|[^\"\\\\])*\")", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeStringColor(accentColor: accentColor)], range: match.range(at: 1))
}
applyCodeRegex("\\b(true|false|null)\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeKeywordColor(accentColor: accentColor)], range: match.range)
}
applyCodeRegex("-?\\b\\d+(?:\\.\\d+)?\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
}
case "yaml":
applyCodeRegex("^\\s*[A-Za-z0-9_-]+\\s*:", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex("#.*$", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
applyCodeRegex("\\b(true|false|null|yes|no)\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
}
case "markdown":
applyCodeRegex("^\\s{0,3}#{1,6}\\s+.*$", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex("(\\*\\*[^*]+\\*\\*|`[^`]+`|\\[[^\\]]+\\]\\([^)]+\\))", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: accentColor], range: match.range)
}
default:
textStorage.addAttributes([.foregroundColor: textColor], range: line.range)
}
}
private static func normalizedCodeLanguage(_ language: String?) -> 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
}

View file

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

View file

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

View file

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

View file

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

View file

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