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.
|
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
|
## AttributedString and NSAttributedString
|
||||||
|
|
||||||
Swift `AttributedString` is useful for renderer-facing APIs and SwiftUI previews.
|
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)"
|
return "task:\(describe(markerRange)):\(describe(checkboxRange)):\(describe(contentRange)):\(checked):\(nestingLevel)"
|
||||||
case .fencedCodeFence(let markerRange, let languageRange):
|
case .fencedCodeFence(let markerRange, let languageRange):
|
||||||
return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil")"
|
return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil")"
|
||||||
case .codeBlockContent:
|
case .codeBlockContent(let language):
|
||||||
return "codeContent"
|
return "codeContent:\(language ?? "plain")"
|
||||||
case .tableRow(let cellRanges, let separatorRanges, let isDivider):
|
case .tableRow(let cellRanges, let separatorRanges, let isDivider):
|
||||||
return "table:\(cellRanges.map(describe).joined(separator: ",")):"
|
return "table:\(cellRanges.map(describe).joined(separator: ",")):"
|
||||||
+ "\(separatorRanges.map(describe).joined(separator: ",")):\(isDivider)"
|
+ "\(separatorRanges.map(describe).joined(separator: ",")):\(isDivider)"
|
||||||
|
|
@ -198,6 +198,7 @@ public struct DocumentPresentationLine: Hashable, Sendable {
|
||||||
|
|
||||||
public struct DocumentPresentationState: Hashable, Sendable {
|
public struct DocumentPresentationState: Hashable, Sendable {
|
||||||
public var activeLineIndex: Int
|
public var activeLineIndex: Int
|
||||||
|
public var editableRegion: EditableRegion
|
||||||
public var lineCount: Int
|
public var lineCount: Int
|
||||||
public var lines: [DocumentPresentationLine]
|
public var lines: [DocumentPresentationLine]
|
||||||
public var elements: [RenderedDocumentElement]
|
public var elements: [RenderedDocumentElement]
|
||||||
|
|
@ -207,17 +208,30 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
||||||
activeLineIndex: Int,
|
activeLineIndex: Int,
|
||||||
lineIndexes: [Int]? = nil
|
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
|
self.lineCount = lineIndex.lineCount
|
||||||
|
|
||||||
let renderer = HybridMarkdownLineRenderer()
|
let renderer = HybridMarkdownLineRenderer()
|
||||||
let selectedLines = lineIndexes.map {
|
let selectedLines = lineIndexes.map {
|
||||||
lineIndex.editorLines(for: $0, activeLineIndex: activeLineIndex)
|
lineIndex.editorLines(for: $0, activeLineIndex: editableRegion.primaryLineIndex)
|
||||||
} ?? lineIndex.editorLines(activeLineIndex: activeLineIndex)
|
} ?? lineIndex.editorLines(activeLineIndex: editableRegion.primaryLineIndex)
|
||||||
let renderPlans = Self.renderPlans(
|
let renderPlans = Self.renderPlans(
|
||||||
for: selectedLines,
|
for: selectedLines,
|
||||||
lineIndex: lineIndex,
|
lineIndex: lineIndex,
|
||||||
activeLineIndex: activeLineIndex,
|
activeLineIndex: editableRegion.primaryLineIndex,
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
needsDocumentContext: lineIndexes != nil
|
needsDocumentContext: lineIndexes != nil
|
||||||
)
|
)
|
||||||
|
|
@ -226,7 +240,7 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
||||||
var collectedElements: [RenderedDocumentElement] = []
|
var collectedElements: [RenderedDocumentElement] = []
|
||||||
|
|
||||||
for line in selectedLines {
|
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 renderPlan = renderPlansByLine[line.index] ?? renderer.renderPlan(for: line)
|
||||||
let lineElements = Self.elements(for: renderPlan, state: state)
|
let lineElements = Self.elements(for: renderPlan, state: state)
|
||||||
presentationLines.append(DocumentPresentationLine(
|
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 {
|
private extension NSRange {
|
||||||
var upperBound: Int {
|
var upperBound: Int {
|
||||||
location + length
|
location + length
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
private var programmaticUpdateDepth = 0
|
private var programmaticUpdateDepth = 0
|
||||||
private var lastStyledText: String?
|
private var lastStyledText: String?
|
||||||
private var lastStyledActiveLineIndex: Int?
|
private var lastStyledActiveLineIndex: Int?
|
||||||
|
private var lastStyledEditableRegion: EditableRegion?
|
||||||
private var pendingEdit: DocumentLineIndexEdit?
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
private var hasUserActivatedEditing = false
|
private var hasUserActivatedEditing = false
|
||||||
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
||||||
|
|
@ -316,8 +317,9 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: NSTextView) {
|
func applyHybridAttributes(to textView: NSTextView) {
|
||||||
guard let textStorage = textView.textStorage else { return }
|
guard let textStorage = textView.textStorage else { return }
|
||||||
let activeLineIndex = presentationActiveLineIndex(in: textView)
|
let editableRegion = presentationEditableRegion(in: textView)
|
||||||
let invalidationPlan = invalidationPlan(for: textView.string, activeLineIndex: activeLineIndex)
|
let activeLineIndex = editableRegion.primaryLineIndex
|
||||||
|
let invalidationPlan = invalidationPlan(for: textView.string, editableRegion: editableRegion)
|
||||||
guard invalidationPlan.requiresStyling else { return }
|
guard invalidationPlan.requiresStyling else { return }
|
||||||
|
|
||||||
let selectedRange = textView.selectedRange()
|
let selectedRange = textView.selectedRange()
|
||||||
|
|
@ -336,7 +338,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
textColor: .labelColor,
|
textColor: .labelColor,
|
||||||
secondaryTextColor: .secondaryLabelColor,
|
secondaryTextColor: .secondaryLabelColor,
|
||||||
accentColor: .controlAccentColor,
|
accentColor: .controlAccentColor,
|
||||||
usesRenderedControls: true
|
usesRenderedControls: true,
|
||||||
|
editableRegion: editableRegion
|
||||||
)
|
)
|
||||||
if textView.selectedRange() != selectedRange,
|
if textView.selectedRange() != selectedRange,
|
||||||
selectedRange.location <= textView.string.utf16.count {
|
selectedRange.location <= textView.string.utf16.count {
|
||||||
|
|
@ -347,6 +350,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
lastStyledText = textView.string
|
lastStyledText = textView.string
|
||||||
lastStyledActiveLineIndex = activeLineIndex
|
lastStyledActiveLineIndex = activeLineIndex
|
||||||
|
lastStyledEditableRegion = editableRegion
|
||||||
syncChecklistControls(
|
syncChecklistControls(
|
||||||
in: textView,
|
in: textView,
|
||||||
stylingResult: stylingResult,
|
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,
|
guard hasUserActivatedEditing,
|
||||||
textView.window?.firstResponder === textView
|
textView.window?.firstResponder === textView
|
||||||
else { return -1 }
|
else { return .none() }
|
||||||
return currentLineIndex.lineIndex(containing: textView.selectedRange().location)
|
return EditableRegion.selection(textView.selectedRange(), in: currentLineIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
func activateEditingPresentation(in textView: NSTextView) {
|
func activateEditingPresentation(in textView: NSTextView) {
|
||||||
|
|
@ -397,6 +401,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
func invalidateStylingCache() {
|
func invalidateStylingCache() {
|
||||||
lastStyledText = nil
|
lastStyledText = nil
|
||||||
lastStyledActiveLineIndex = nil
|
lastStyledActiveLineIndex = nil
|
||||||
|
lastStyledEditableRegion = nil
|
||||||
hasUserActivatedEditing = false
|
hasUserActivatedEditing = false
|
||||||
removeChecklistControls()
|
removeChecklistControls()
|
||||||
}
|
}
|
||||||
|
|
@ -405,13 +410,19 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
programmaticUpdateDepth > 0
|
programmaticUpdateDepth > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
|
||||||
EditorDirtyLineInvalidator.plan(
|
let previousEditableRegion = lastStyledEditableRegion
|
||||||
|
let plan = EditorDirtyLineInvalidator.plan(
|
||||||
previousText: lastStyledText,
|
previousText: lastStyledText,
|
||||||
currentLineIndex: currentLineIndex,
|
currentLineIndex: currentLineIndex,
|
||||||
edit: pendingEdit,
|
edit: pendingEdit,
|
||||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
|
||||||
currentActiveLineIndex: activeLineIndex
|
currentActiveLineIndex: editableRegion.primaryLineIndex
|
||||||
|
)
|
||||||
|
return plan.includingEditableRegionTransition(
|
||||||
|
from: previousEditableRegion,
|
||||||
|
to: editableRegion,
|
||||||
|
lineCount: currentLineIndex.lineCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -813,6 +824,10 @@ public final class HybridMarkdownLiveEditorHarness {
|
||||||
isHidden(at: 0)
|
isHidden(at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func characterIsHidden(at location: Int) -> Bool {
|
||||||
|
isHidden(at: location)
|
||||||
|
}
|
||||||
|
|
||||||
public func point(for text: String) -> CGPoint? {
|
public func point(for text: String) -> CGPoint? {
|
||||||
let textRange = (textView.string as NSString).range(of: text)
|
let textRange = (textView.string as NSString).range(of: text)
|
||||||
guard textRange.location != NSNotFound,
|
guard textRange.location != NSNotFound,
|
||||||
|
|
@ -975,6 +990,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
private var programmaticUpdateDepth = 0
|
private var programmaticUpdateDepth = 0
|
||||||
private var lastStyledText: String?
|
private var lastStyledText: String?
|
||||||
private var lastStyledActiveLineIndex: Int?
|
private var lastStyledActiveLineIndex: Int?
|
||||||
|
private var lastStyledEditableRegion: EditableRegion?
|
||||||
private var pendingEdit: DocumentLineIndexEdit?
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
@ -1014,10 +1030,11 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: UITextView) {
|
func applyHybridAttributes(to textView: UITextView) {
|
||||||
let activeLineIndex = textView.isFirstResponder
|
let editableRegion = textView.isFirstResponder
|
||||||
? currentLineIndex.lineIndex(containing: textView.selectedRange.location)
|
? EditableRegion.selection(textView.selectedRange, in: currentLineIndex)
|
||||||
: -1
|
: .none()
|
||||||
let invalidationPlan = invalidationPlan(for: textView.text, activeLineIndex: activeLineIndex)
|
let activeLineIndex = editableRegion.primaryLineIndex
|
||||||
|
let invalidationPlan = invalidationPlan(for: textView.text, editableRegion: editableRegion)
|
||||||
guard invalidationPlan.requiresStyling else { return }
|
guard invalidationPlan.requiresStyling else { return }
|
||||||
|
|
||||||
let selectedRange = textView.selectedRange
|
let selectedRange = textView.selectedRange
|
||||||
|
|
@ -1035,7 +1052,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
|
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
|
||||||
textColor: .label,
|
textColor: .label,
|
||||||
secondaryTextColor: .secondaryLabel,
|
secondaryTextColor: .secondaryLabel,
|
||||||
accentColor: .systemBlue
|
accentColor: .systemBlue,
|
||||||
|
editableRegion: editableRegion
|
||||||
)
|
)
|
||||||
if textView.selectedRange != selectedRange,
|
if textView.selectedRange != selectedRange,
|
||||||
selectedRange.location <= textView.text.utf16.count {
|
selectedRange.location <= textView.text.utf16.count {
|
||||||
|
|
@ -1047,6 +1065,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
|
|
||||||
lastStyledText = textView.text
|
lastStyledText = textView.text
|
||||||
lastStyledActiveLineIndex = activeLineIndex
|
lastStyledActiveLineIndex = activeLineIndex
|
||||||
|
lastStyledEditableRegion = editableRegion
|
||||||
parent.onRenderPass(EditorRenderPassMetric(
|
parent.onRenderPass(EditorRenderPassMetric(
|
||||||
reason: invalidationPlan.reason,
|
reason: invalidationPlan.reason,
|
||||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||||
|
|
@ -1078,19 +1097,26 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
func invalidateStylingCache() {
|
func invalidateStylingCache() {
|
||||||
lastStyledText = nil
|
lastStyledText = nil
|
||||||
lastStyledActiveLineIndex = nil
|
lastStyledActiveLineIndex = nil
|
||||||
|
lastStyledEditableRegion = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPerformingProgrammaticUpdate: Bool {
|
private var isPerformingProgrammaticUpdate: Bool {
|
||||||
programmaticUpdateDepth > 0
|
programmaticUpdateDepth > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
|
||||||
EditorDirtyLineInvalidator.plan(
|
let previousEditableRegion = lastStyledEditableRegion
|
||||||
|
let plan = EditorDirtyLineInvalidator.plan(
|
||||||
previousText: lastStyledText,
|
previousText: lastStyledText,
|
||||||
currentLineIndex: currentLineIndex,
|
currentLineIndex: currentLineIndex,
|
||||||
edit: pendingEdit,
|
edit: pendingEdit,
|
||||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
|
||||||
currentActiveLineIndex: activeLineIndex
|
currentActiveLineIndex: editableRegion.primaryLineIndex
|
||||||
|
)
|
||||||
|
return plan.includingEditableRegionTransition(
|
||||||
|
from: previousEditableRegion,
|
||||||
|
to: editableRegion,
|
||||||
|
lineCount: currentLineIndex.lineCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1108,12 +1134,14 @@ struct MarkdownTextStylingResult {
|
||||||
var styledLineCount: Int
|
var styledLineCount: Int
|
||||||
var styledLineIndexes: [Int]
|
var styledLineIndexes: [Int]
|
||||||
var renderedTasks: [RenderedTaskElement]
|
var renderedTasks: [RenderedTaskElement]
|
||||||
|
var editableRegion: EditableRegion
|
||||||
|
|
||||||
static let empty = MarkdownTextStylingResult(
|
static let empty = MarkdownTextStylingResult(
|
||||||
totalLineCount: 0,
|
totalLineCount: 0,
|
||||||
styledLineCount: 0,
|
styledLineCount: 0,
|
||||||
styledLineIndexes: [],
|
styledLineIndexes: [],
|
||||||
renderedTasks: []
|
renderedTasks: [],
|
||||||
|
editableRegion: .none()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1135,15 +1163,19 @@ enum MarkdownTextStyler {
|
||||||
textColor: PlatformColor,
|
textColor: PlatformColor,
|
||||||
secondaryTextColor: PlatformColor,
|
secondaryTextColor: PlatformColor,
|
||||||
accentColor: PlatformColor,
|
accentColor: PlatformColor,
|
||||||
usesRenderedControls: Bool = false
|
usesRenderedControls: Bool = false,
|
||||||
|
editableRegion: EditableRegion? = nil
|
||||||
) -> MarkdownTextStylingResult {
|
) -> MarkdownTextStylingResult {
|
||||||
|
let resolvedEditableRegion = editableRegion
|
||||||
|
?? (activeLineIndex >= 0 ? EditableRegion(lineIndexes: [activeLineIndex]) : .none())
|
||||||
let fullRange = NSRange(location: 0, length: textStorage.length)
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
||||||
guard fullRange.length > 0 else {
|
guard fullRange.length > 0 else {
|
||||||
return MarkdownTextStylingResult(
|
return MarkdownTextStylingResult(
|
||||||
totalLineCount: lineIndex.lineCount,
|
totalLineCount: lineIndex.lineCount,
|
||||||
styledLineCount: lineIndex.lineCount,
|
styledLineCount: lineIndex.lineCount,
|
||||||
styledLineIndexes: Array(0..<lineIndex.lineCount),
|
styledLineIndexes: Array(0..<lineIndex.lineCount),
|
||||||
renderedTasks: []
|
renderedTasks: [],
|
||||||
|
editableRegion: resolvedEditableRegion
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1154,7 +1186,7 @@ enum MarkdownTextStyler {
|
||||||
|
|
||||||
let presentationState = DocumentPresentationState(
|
let presentationState = DocumentPresentationState(
|
||||||
lineIndex: lineIndex,
|
lineIndex: lineIndex,
|
||||||
activeLineIndex: activeLineIndex,
|
editableRegion: resolvedEditableRegion,
|
||||||
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
|
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
|
||||||
)
|
)
|
||||||
var styledLineCount = 0
|
var styledLineCount = 0
|
||||||
|
|
@ -1193,7 +1225,8 @@ enum MarkdownTextStyler {
|
||||||
totalLineCount: presentationState.lineCount,
|
totalLineCount: presentationState.lineCount,
|
||||||
styledLineCount: styledLineCount,
|
styledLineCount: styledLineCount,
|
||||||
styledLineIndexes: styledLineIndexes,
|
styledLineIndexes: styledLineIndexes,
|
||||||
renderedTasks: presentationState.renderedTasks
|
renderedTasks: presentationState.renderedTasks,
|
||||||
|
editableRegion: presentationState.editableRegion
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1318,8 +1351,16 @@ enum MarkdownTextStyler {
|
||||||
} else {
|
} else {
|
||||||
hideSyntax(in: textStorage, range: line.range)
|
hideSyntax(in: textStorage, range: line.range)
|
||||||
}
|
}
|
||||||
case .codeBlockContent:
|
case .codeBlockContent(let language):
|
||||||
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
|
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):
|
case .tableRow(_, let separatorRanges, let isDivider):
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.font: monospacedFont(size: 15, weight: .regular),
|
.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(
|
private static func styleSourceLineMarkers(
|
||||||
in textStorage: NSTextStorage,
|
in textStorage: NSTextStorage,
|
||||||
line: EditorLine,
|
line: EditorLine,
|
||||||
|
|
@ -1409,6 +1534,19 @@ enum MarkdownTextStyler {
|
||||||
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
|
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 {
|
private static func headingFontSize(level: Int) -> CGFloat {
|
||||||
switch level {
|
switch level {
|
||||||
case 1: 28
|
case 1: 28
|
||||||
|
|
@ -1463,7 +1601,10 @@ enum MarkdownTextStyler {
|
||||||
private static func codeBlockParagraphStyle() -> NSMutableParagraphStyle {
|
private static func codeBlockParagraphStyle() -> NSMutableParagraphStyle {
|
||||||
let paragraph = NSMutableParagraphStyle()
|
let paragraph = NSMutableParagraphStyle()
|
||||||
paragraph.lineSpacing = 3
|
paragraph.lineSpacing = 3
|
||||||
paragraph.paragraphSpacing = 2
|
paragraph.paragraphSpacing = 4
|
||||||
|
paragraph.paragraphSpacingBefore = 2
|
||||||
|
paragraph.firstLineHeadIndent = 14
|
||||||
|
paragraph.headIndent = 14
|
||||||
return paragraph
|
return paragraph
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1565,6 +1706,18 @@ enum MarkdownTextStyler {
|
||||||
private static func clearColor() -> NSColor {
|
private static func clearColor() -> NSColor {
|
||||||
.clear
|
.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)
|
#elseif os(iOS)
|
||||||
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
||||||
UIFont.systemFont(ofSize: size, weight: weight)
|
UIFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
|
@ -1581,6 +1734,18 @@ enum MarkdownTextStyler {
|
||||||
private static func clearColor() -> UIColor {
|
private static func clearColor() -> UIColor {
|
||||||
.clear
|
.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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ public enum HybridMarkdownLineKind: Hashable, Sendable {
|
||||||
nestingLevel: Int
|
nestingLevel: Int
|
||||||
)
|
)
|
||||||
case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?)
|
case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?)
|
||||||
case codeBlockContent
|
case codeBlockContent(language: String?)
|
||||||
case tableRow(cellRanges: [NSRange], separatorRanges: [NSRange], isDivider: Bool)
|
case tableRow(cellRanges: [NSRange], separatorRanges: [NSRange], isDivider: Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,17 +61,24 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
||||||
|
|
||||||
public func renderPlans(for lines: [EditorLine]) -> [HybridMarkdownLineRenderPlan] {
|
public func renderPlans(for lines: [EditorLine]) -> [HybridMarkdownLineRenderPlan] {
|
||||||
var isInCodeBlock = false
|
var isInCodeBlock = false
|
||||||
|
var codeBlockLanguage: String?
|
||||||
var plans: [HybridMarkdownLineRenderPlan] = []
|
var plans: [HybridMarkdownLineRenderPlan] = []
|
||||||
|
|
||||||
for line in lines {
|
for line in lines {
|
||||||
let nonCodeKind = lineKind(for: line)
|
let nonCodeKind = lineKind(for: line)
|
||||||
let kind: HybridMarkdownLineKind
|
let kind: HybridMarkdownLineKind
|
||||||
|
|
||||||
if case .fencedCodeFence = nonCodeKind {
|
if case .fencedCodeFence(_, let languageRange) = nonCodeKind {
|
||||||
kind = nonCodeKind
|
kind = nonCodeKind
|
||||||
isInCodeBlock.toggle()
|
if isInCodeBlock {
|
||||||
|
isInCodeBlock = false
|
||||||
|
codeBlockLanguage = nil
|
||||||
|
} else {
|
||||||
|
isInCodeBlock = true
|
||||||
|
codeBlockLanguage = language(in: line, range: languageRange)
|
||||||
|
}
|
||||||
} else if isInCodeBlock {
|
} else if isInCodeBlock {
|
||||||
kind = .codeBlockContent
|
kind = .codeBlockContent(language: codeBlockLanguage)
|
||||||
} else {
|
} else {
|
||||||
kind = nonCodeKind
|
kind = nonCodeKind
|
||||||
}
|
}
|
||||||
|
|
@ -97,8 +104,12 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
||||||
|
|
||||||
if case .fencedCodeFence = nonCodeKind {
|
if case .fencedCodeFence = nonCodeKind {
|
||||||
kind = nonCodeKind
|
kind = nonCodeKind
|
||||||
} else if isInsideFencedCodeBlock(line.index, in: lineIndex, activeLineIndex: activeLineIndex) {
|
} else if let context = fencedCodeBlockContext(
|
||||||
kind = .codeBlockContent
|
containing: line.index,
|
||||||
|
in: lineIndex,
|
||||||
|
activeLineIndex: activeLineIndex
|
||||||
|
) {
|
||||||
|
kind = .codeBlockContent(language: context.language)
|
||||||
} else {
|
} else {
|
||||||
kind = nonCodeKind
|
kind = nonCodeKind
|
||||||
}
|
}
|
||||||
|
|
@ -506,24 +517,44 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
||||||
source.first { $0 != " " && $0 != "\t" }
|
source.first { $0 != " " && $0 != "\t" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isInsideFencedCodeBlock(
|
private func fencedCodeBlockContext(
|
||||||
_ lineNumber: Int,
|
containing lineNumber: Int,
|
||||||
in lineIndex: DocumentLineIndex,
|
in lineIndex: DocumentLineIndex,
|
||||||
activeLineIndex: Int
|
activeLineIndex: Int
|
||||||
) -> Bool {
|
) -> CodeBlockContext? {
|
||||||
guard lineNumber != activeLineIndex, lineNumber > 0 else { return false }
|
guard lineNumber != activeLineIndex, lineNumber > 0 else { return nil }
|
||||||
|
|
||||||
var isInside = false
|
var isInside = false
|
||||||
|
var language: String?
|
||||||
var lineCursor = 0
|
var lineCursor = 0
|
||||||
while lineCursor < lineNumber {
|
while lineCursor < lineNumber {
|
||||||
if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex),
|
if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex),
|
||||||
case .fencedCodeFence = lineKind(for: line) {
|
case .fencedCodeFence(_, let languageRange) = lineKind(for: line) {
|
||||||
isInside.toggle()
|
if isInside {
|
||||||
|
isInside = false
|
||||||
|
language = nil
|
||||||
|
} else {
|
||||||
|
isInside = true
|
||||||
|
language = self.language(in: line, range: languageRange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lineCursor += 1
|
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 {
|
private struct InlineMatch {
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,41 @@ final class DocumentPresentationStateTests: XCTestCase {
|
||||||
|
|
||||||
XCTAssertEqual(presentation.lines.count, 1)
|
XCTAssertEqual(presentation.lines.count, 1)
|
||||||
XCTAssertEqual(presentation.lines[0].state, .rendered)
|
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)
|
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))
|
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
|
#endif
|
||||||
|
|
||||||
|
private extension NSRange {
|
||||||
|
var upperBound: Int {
|
||||||
|
location + length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,18 +47,21 @@ final class MarkdownTextStylerRenderingTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRenderedCodeBlockHidesFencesAndStylesCodeContent() {
|
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 storage = styledStorage(source: source, activeLineIndex: 0)
|
||||||
let nsSource = source as NSString
|
let nsSource = source as NSString
|
||||||
let openingFence = nsSource.range(of: "```swift")
|
let openingFence = nsSource.range(of: "```swift")
|
||||||
let language = nsSource.range(of: "swift")
|
let language = nsSource.range(of: "swift")
|
||||||
let code = nsSource.range(of: "let value = 42")
|
let code = nsSource.range(of: "let value = 42")
|
||||||
|
let keyword = nsSource.range(of: "struct")
|
||||||
let closingFence = nsSource.range(of: "```", options: .backwards)
|
let closingFence = nsSource.range(of: "```", options: .backwards)
|
||||||
|
|
||||||
XCTAssertTrue(isHidden(storage, at: openingFence.location))
|
XCTAssertTrue(isHidden(storage, at: openingFence.location))
|
||||||
XCTAssertFalse(isHidden(storage, at: language.location))
|
XCTAssertFalse(isHidden(storage, at: language.location))
|
||||||
XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
||||||
XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true)
|
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))
|
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)
|
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 {
|
private func styledStorage(source: String, activeLineIndex: Int) -> NSTextStorage {
|
||||||
let storage = NSTextStorage(string: source)
|
let storage = NSTextStorage(string: source)
|
||||||
let lineIndex = DocumentLineIndex(source: source)
|
let lineIndex = DocumentLineIndex(source: source)
|
||||||
|
|
@ -123,5 +136,9 @@ final class MarkdownTextStylerRenderingTests: XCTestCase {
|
||||||
}
|
}
|
||||||
return font
|
return font
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func paragraphStyle(in storage: NSTextStorage, at location: Int) -> NSParagraphStyle {
|
||||||
|
storage.attribute(.paragraphStyle, at: location, effectiveRange: nil) as? NSParagraphStyle ?? NSParagraphStyle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue