feat(editor): support editable regions and code blocks
This commit is contained in:
parent
3e7fe6ef03
commit
941fcd5d56
10 changed files with 585 additions and 49 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
125
Sources/SaplingEditor/EditableRegion.swift
Normal file
125
Sources/SaplingEditor/EditableRegion.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue