Compare commits
4 commits
3da06e096b
...
2c923c245a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c923c245a | |||
| 183493022f | |||
| 95be4348cd | |||
| d12a0a58ed |
9 changed files with 874 additions and 76 deletions
|
|
@ -1461,9 +1461,9 @@ Code blocks now behave as block-level editing units and have a stronger rendered
|
|||
Rendered behavior:
|
||||
|
||||
- Opening and closing fences are hidden in rendered mode.
|
||||
- The fence language remains visible as a compact label when present.
|
||||
- The fence language is available to the presentation layer as a compact label when present.
|
||||
- Code content uses a monospaced font.
|
||||
- Code block paragraphs receive a tinted background, indentation, and tighter spacing.
|
||||
- Code block paragraphs receive indentation and tighter spacing; the unified background is drawn by the live text view container layer.
|
||||
- Code content receives modest syntax highlighting.
|
||||
|
||||
Editable behavior:
|
||||
|
|
@ -1488,6 +1488,156 @@ Syntax highlighting scope:
|
|||
|
||||
The highlighter is deliberately line-local and regex-based. It does not attempt IDE-level parsing, multi-line string state, or semantic analysis. That keeps highlighting cheap enough to run inside dirty-line presentation passes and keeps the architecture extensible for a later proper syntax engine.
|
||||
|
||||
## Finding #24 — Code Block Containers
|
||||
|
||||
Milestone 3.7 upgrades rendered code blocks from styled lines into first-class presentation containers. The semantic model remains `RenderedCodeBlockElement`, but the live AppKit materialization now creates `CodeBlockContainerPresentation` values and lets `EditorTextView` draw one rounded block behind the code glyphs.
|
||||
|
||||
Previous implementation:
|
||||
|
||||
- Opening fences hid only the fence marker while the language remained styled in the text line.
|
||||
- Code content used per-line background attributes.
|
||||
- Closing fences were hidden, but the rendered block still read visually as separate styled paragraphs.
|
||||
|
||||
New presentation pipeline:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Markdown["Markdown source"] --> Lines["DocumentLineIndex"]
|
||||
Lines --> State["DocumentPresentationState"]
|
||||
State --> Block["RenderedCodeBlockElement"]
|
||||
Block --> Container["CodeBlockContainerPresentation"]
|
||||
Container --> TextView["EditorTextView.drawBackground(in:)"]
|
||||
TextView --> Visual["Rounded container + header + highlighted glyphs"]
|
||||
```
|
||||
|
||||
Rendered behavior:
|
||||
|
||||
- Opening and closing fences are hidden, including the raw language token.
|
||||
- The language appears in a dedicated header bar.
|
||||
- Code content keeps monospaced text and lightweight syntax highlighting.
|
||||
- Background is drawn once for the whole block rather than once per line.
|
||||
- The container has rounded corners, a separator between header and body, and horizontal padding via paragraph indentation.
|
||||
|
||||
Editing lifecycle:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> RenderedContainer
|
||||
RenderedContainer --> SourceBlock: EditableRegion intersects any code block line
|
||||
SourceBlock --> RenderedContainer: EditableRegion leaves the block
|
||||
```
|
||||
|
||||
`DocumentPresentationState.renderedCodeBlocks(in:editableRegion:)` filters out blocks touched by the editable region, so source mode never draws a container behind visible fences. This keeps the existing Milestone 3.6 code-block editing contract intact: entering any line inside the fenced block makes the entire block editable source; leaving the block restores the rendered container.
|
||||
|
||||
Performance impact:
|
||||
|
||||
- Syntax highlighting remains line-local.
|
||||
- The container model is rebuilt only during the same styling passes that already materialize presentation state.
|
||||
- Documents without fenced code blocks return immediately from rendered-code-block discovery.
|
||||
- Drawing uses TextKit's existing line fragment geometry and does not mutate text storage.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- `testLiveRenderedCodeBlockUsesSingleContainerPresentation` exercises the real `NSTextView` harness and verifies one rendered container, a language header, hidden fences, source-mode removal when the cursor enters the block, and restoration when the cursor leaves.
|
||||
- `testRenderedCodeBlockHidesFencesAndStylesCodeContent` verifies that text storage no longer relies on per-line background attributes for code block presentation.
|
||||
|
||||
Future container affordances:
|
||||
|
||||
- Copy buttons and collapse buttons fit naturally as overlay controls anchored to `CodeBlockContainerPresentation` frames.
|
||||
- Line numbers are feasible as a body gutter drawn by `EditorTextView`, but should be deferred until wrapping and selection behavior are validated.
|
||||
- These affordances do not require changing the Markdown source model; they are presentation-layer additions.
|
||||
|
||||
## Finding #25 — Presentation Stability
|
||||
|
||||
Milestone 3.8 addresses viewport movement during rendered/source transitions. The remaining visible jump was not caused by the render model; it came from preserving the scroll view's numeric origin while TextKit changed the geometry of the line being edited.
|
||||
|
||||
Root cause:
|
||||
|
||||
- Rendered headings use larger fonts and heading paragraph spacing.
|
||||
- Source headings use monospaced source styling and active-line background.
|
||||
- Rendered code blocks use hidden fence lines, container spacing, and monospaced body text.
|
||||
- Source code blocks reveal the whole fenced block.
|
||||
- `applyHybridAttributes` restored the previous `NSClipView.bounds.origin`, but the edited glyph could move relative to that origin after attributes changed.
|
||||
- Programmatic selection also called `scrollRangeToVisible` even when the selection was already visible, which could pre-scroll before the presentation transition ran.
|
||||
- The first implementation applied anchoring too broadly from `applyHybridAttributes`, so ordinary selection changes and keyboard navigation could also receive custom scroll correction after AppKit had already handled caret visibility.
|
||||
|
||||
Corrected stabilization model:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Before["Before styling"] --> Capture["Capture source location + viewport-relative glyph Y"]
|
||||
Capture --> Style["Apply rendered/source attributes"]
|
||||
Style --> Relayout["TextKit lays out changed character range"]
|
||||
Relayout --> Restore["Scroll so same source location has same viewport-relative Y"]
|
||||
```
|
||||
|
||||
The anchor is source-based, not element-specific. `ViewportPresentationAnchor` captures a character location and its viewport-relative Y position before a presentation transition. After styling, the coordinator asks TextKit for the same source location's new glyph position and scrolls the clip view by the delta. This applies equally to headings, task lists, code blocks, tables, and future rendered widgets because the mechanism does not inspect the rendered element type during restoration.
|
||||
|
||||
Anchoring policy:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Event["Editor event"] --> Keyboard{"Keyboard navigation?"}
|
||||
Keyboard -- Yes --> Native["Let NSTextView own scrolling"]
|
||||
Keyboard -- No --> Selection{"Programmatic selection?"}
|
||||
Selection -- Yes --> Native
|
||||
Selection -- No --> Transition{"Editable region changed?"}
|
||||
Transition -- No --> Native
|
||||
Transition -- Yes --> Presentation{"Mouse or focus presentation transition?"}
|
||||
Presentation -- Yes --> Anchor["Capture and restore presentation anchor"]
|
||||
Presentation -- No --> Native
|
||||
```
|
||||
|
||||
Viewport stabilization now runs only when all of these are true:
|
||||
|
||||
- A styling pass is required.
|
||||
- The dirty reason is an active/editable-region transition rather than source text editing or initial render.
|
||||
- The editable region actually changed.
|
||||
- The selection is collapsed; range selections stay native.
|
||||
- The trigger is mouse presentation entry or focus-driven presentation exit.
|
||||
|
||||
Viewport stabilization explicitly does not run for:
|
||||
|
||||
- Arrow-key navigation.
|
||||
- Page up/down and other key-driven selection movement.
|
||||
- Mouse drag or range selection.
|
||||
- Programmatic selection updates.
|
||||
- Source edits.
|
||||
- View updates that do not change the editable region.
|
||||
|
||||
Anchor selection:
|
||||
|
||||
- The selected source location is the default anchor.
|
||||
- If selection is on hidden or structural syntax for headings, lists, blockquotes, or tasks, the anchor moves to the visible content range for that line.
|
||||
- Programmatic selection changes only scroll when the target point is outside the visible viewport; visible targets preserve the existing origin.
|
||||
- `ViewportStabilityEvent` records the event cause, render reason, stabilization decision, whether an anchor was captured, and whether a scroll restoration ran. This is intentionally test-facing instrumentation so regressions show which system tried to own scroll behavior.
|
||||
|
||||
Geometry audit:
|
||||
|
||||
| Element | Rendered geometry | Source geometry | Stability handling |
|
||||
| --- | --- | --- | --- |
|
||||
| Heading | Larger font and heading spacing | Monospaced active line | Anchor selected heading text |
|
||||
| Task list | Hidden list marker, overlay checkbox | Raw marker and checkbox syntax | Anchor selected task content when syntax is selected |
|
||||
| Code block | Hidden fences, rendered container, code body | Full fenced source block | Anchor selected code/fence source location |
|
||||
| Paragraph inline markup | Hidden delimiters, styled content | Raw Markdown delimiters | Anchor selected source location |
|
||||
|
||||
Validation:
|
||||
|
||||
- `testHeadingTransitionKeepsEditedContentVisuallyAnchored` reproduced the heading jump and now verifies the heading text keeps the same viewport-relative Y position when entering and leaving source mode.
|
||||
- `testCodeBlockTransitionKeepsEditedContentVisuallyAnchored` verifies the same contract for entering and leaving a rendered code block.
|
||||
- `testKeyboardNavigationDoesNotRunViewportStabilization` verifies arrow-key-style selection movement is logged as native keyboard navigation and does not run custom scroll restoration.
|
||||
- The tests use `HybridMarkdownLiveEditorHarness`, so they exercise the same `NSTextView`, coordinator, TextKit layout, and scroll-view path as the app.
|
||||
|
||||
Performance impact:
|
||||
|
||||
- Anchor measurement uses `NSLayoutManager.ensureLayout(forCharacterRange:)` on the anchor character rather than full-document layout.
|
||||
- Dirty-line styling remains unchanged.
|
||||
- The anchoring work only runs when a presentation pass is already required.
|
||||
|
||||
Known boundary:
|
||||
|
||||
At the extreme end of a document, the scroll origin may still clamp if the document height changes and there is no remaining scrollable space below the viewport. That is a natural scroll-view constraint rather than interaction-history nondeterminism.
|
||||
|
||||
## AttributedString and NSAttributedString
|
||||
|
||||
Swift `AttributedString` is useful for renderer-facing APIs and SwiftUI previews.
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ public struct DocumentRenderModel: Hashable, Sendable {
|
|||
return "orderedList:\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)"
|
||||
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
||||
return "task:\(describe(markerRange)):\(describe(checkboxRange)):\(describe(contentRange)):\(checked):\(nestingLevel)"
|
||||
case .fencedCodeFence(let markerRange, let languageRange):
|
||||
return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil")"
|
||||
case .fencedCodeFence(let markerRange, let languageRange, let role):
|
||||
return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil"):\(role.rawValue)"
|
||||
case .codeBlockContent(let language):
|
||||
return "codeContent:\(language ?? "plain")"
|
||||
case .tableRow(let cellRanges, let separatorRanges, let isDivider):
|
||||
|
|
@ -253,7 +253,10 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
|||
}
|
||||
|
||||
if lineIndexes == nil {
|
||||
collectedElements.append(contentsOf: Self.codeBlockElements(from: renderPlans))
|
||||
collectedElements.append(contentsOf: Self.codeBlockElements(
|
||||
from: renderPlans,
|
||||
editableRegion: editableRegion
|
||||
))
|
||||
}
|
||||
|
||||
self.lines = presentationLines
|
||||
|
|
@ -295,6 +298,20 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
|||
).renderedTasks
|
||||
}
|
||||
|
||||
public static func renderedCodeBlocks(
|
||||
in lineIndex: DocumentLineIndex,
|
||||
editableRegion: EditableRegion
|
||||
) -> [RenderedCodeBlockElement] {
|
||||
guard lineIndex.source.contains("```") || lineIndex.source.contains("~~~") else {
|
||||
return []
|
||||
}
|
||||
|
||||
return DocumentPresentationState(
|
||||
lineIndex: lineIndex,
|
||||
editableRegion: editableRegion
|
||||
).renderedCodeBlocks
|
||||
}
|
||||
|
||||
fileprivate static func renderPlans(
|
||||
for lines: [EditorLine],
|
||||
lineIndex: DocumentLineIndex,
|
||||
|
|
@ -404,30 +421,35 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
|||
return elements
|
||||
}
|
||||
|
||||
fileprivate static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] {
|
||||
fileprivate static func codeBlockElements(
|
||||
from plans: [HybridMarkdownLineRenderPlan],
|
||||
editableRegion: EditableRegion = .none()
|
||||
) -> [RenderedDocumentElement] {
|
||||
var elements: [RenderedDocumentElement] = []
|
||||
var openLineIndexes: [Int] = []
|
||||
var openRange: NSRange?
|
||||
var openLanguageRange: NSRange?
|
||||
|
||||
for plan in plans {
|
||||
guard case .fencedCodeFence(let markerRange, let languageRange) = plan.kind else {
|
||||
guard case .fencedCodeFence(let markerRange, let languageRange, let role) = plan.kind else {
|
||||
if openRange != nil {
|
||||
openLineIndexes.append(plan.line.index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let sourceRange = openRange {
|
||||
if role == .closing, let sourceRange = openRange {
|
||||
openLineIndexes.append(plan.line.index)
|
||||
elements.append(.codeBlock(RenderedCodeBlockElement(
|
||||
appendCodeBlockElement(
|
||||
to: &elements,
|
||||
lineIndexes: openLineIndexes,
|
||||
sourceRange: NSRange(
|
||||
location: sourceRange.location,
|
||||
length: plan.line.range.upperBound - sourceRange.location
|
||||
),
|
||||
languageRange: openLanguageRange
|
||||
)))
|
||||
languageRange: openLanguageRange,
|
||||
editableRegion: editableRegion
|
||||
)
|
||||
openLineIndexes.removeAll()
|
||||
openRange = nil
|
||||
openLanguageRange = nil
|
||||
|
|
@ -440,18 +462,35 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
|||
|
||||
if let sourceRange = openRange,
|
||||
let lastLine = plans.last?.line {
|
||||
elements.append(.codeBlock(RenderedCodeBlockElement(
|
||||
appendCodeBlockElement(
|
||||
to: &elements,
|
||||
lineIndexes: openLineIndexes,
|
||||
sourceRange: NSRange(
|
||||
location: sourceRange.location,
|
||||
length: lastLine.range.upperBound - sourceRange.location
|
||||
),
|
||||
languageRange: openLanguageRange
|
||||
)))
|
||||
languageRange: openLanguageRange,
|
||||
editableRegion: editableRegion
|
||||
)
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
private static func appendCodeBlockElement(
|
||||
to elements: inout [RenderedDocumentElement],
|
||||
lineIndexes: [Int],
|
||||
sourceRange: NSRange,
|
||||
languageRange: NSRange?,
|
||||
editableRegion: EditableRegion
|
||||
) {
|
||||
guard !lineIndexes.contains(where: editableRegion.contains) else { return }
|
||||
elements.append(.codeBlock(RenderedCodeBlockElement(
|
||||
lineIndexes: lineIndexes,
|
||||
sourceRange: sourceRange,
|
||||
languageRange: languageRange
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSRange {
|
||||
|
|
|
|||
|
|
@ -206,10 +206,10 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
textView.string = text
|
||||
textView.delegate = context.coordinator
|
||||
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
|
||||
coordinator?.applyHybridAttributes(to: textView)
|
||||
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
||||
}
|
||||
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView in
|
||||
coordinator?.activateEditingPresentation(in: textView)
|
||||
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView, interactionKind in
|
||||
coordinator?.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||
}
|
||||
textView.isRichText = false
|
||||
textView.isEditable = true
|
||||
|
|
@ -233,6 +233,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
scrollView.editorTextView = textView
|
||||
scrollView.onEditorLayoutInvalidated = { [weak coordinator = context.coordinator] textView in
|
||||
coordinator?.syncChecklistControlFrames(in: textView)
|
||||
(textView as? EditorTextView)?.invalidateCodeBlockContainers()
|
||||
}
|
||||
scrollView.updateEditorInsets()
|
||||
context.coordinator.applyHybridAttributes(to: textView)
|
||||
|
|
@ -254,7 +255,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
let selectedRange = selection.range
|
||||
if textView.selectedRange() != selectedRange,
|
||||
selectedRange.location <= textView.string.utf16.count {
|
||||
context.coordinator.setSelection(selectedRange, in: textView)
|
||||
context.coordinator.setSelection(selectedRange, in: textView, interactionKind: .programmatic)
|
||||
}
|
||||
|
||||
context.coordinator.applyHybridAttributes(to: textView)
|
||||
|
|
@ -270,6 +271,9 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
private var pendingEdit: DocumentLineIndexEdit?
|
||||
private var hasUserActivatedEditing = false
|
||||
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
||||
fileprivate var viewportStabilityEvents: [ViewportStabilityEvent] = []
|
||||
private var lastUserInteractionKind: EditorInteractionKind = .programmatic
|
||||
private var pendingSelectionInteractionKind: EditorInteractionKind?
|
||||
|
||||
init(_ parent: NativeMarkdownTextView) {
|
||||
self.parent = parent
|
||||
|
|
@ -302,7 +306,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
}
|
||||
parent.onTextEdit(textView.string, edit, selection)
|
||||
parent.selection = selection
|
||||
applyHybridAttributes(to: textView)
|
||||
applyHybridAttributes(to: textView, cause: .sourceChange)
|
||||
pendingEdit = nil
|
||||
}
|
||||
|
||||
|
|
@ -311,11 +315,16 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
guard let textView = notification.object as? NSTextView else { return }
|
||||
let newSelection = EditorSelection(range: textView.selectedRange())
|
||||
guard parent.selection != newSelection else { return }
|
||||
applyHybridAttributes(to: textView)
|
||||
let interactionKind = pendingSelectionInteractionKind ?? lastUserInteractionKind
|
||||
pendingSelectionInteractionKind = nil
|
||||
applyHybridAttributes(to: textView, cause: .selectionChange(interactionKind))
|
||||
parent.selection = newSelection
|
||||
}
|
||||
|
||||
func applyHybridAttributes(to textView: NSTextView) {
|
||||
func applyHybridAttributes(
|
||||
to textView: NSTextView,
|
||||
cause: PresentationUpdateCause = .viewUpdate
|
||||
) {
|
||||
guard let textStorage = textView.textStorage else { return }
|
||||
let editableRegion = presentationEditableRegion(in: textView)
|
||||
let activeLineIndex = editableRegion.primaryLineIndex
|
||||
|
|
@ -323,7 +332,19 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
guard invalidationPlan.requiresStyling else { return }
|
||||
|
||||
let selectedRange = textView.selectedRange()
|
||||
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
||||
let stabilizationDecision = viewportStabilizationDecision(
|
||||
cause: cause,
|
||||
invalidationPlan: invalidationPlan,
|
||||
editableRegion: editableRegion,
|
||||
selectedRange: selectedRange
|
||||
)
|
||||
let viewportAnchor = stabilizationDecision.shouldStabilize
|
||||
? ViewportPresentationAnchor.capture(
|
||||
in: textView,
|
||||
selectedRange: selectedRange,
|
||||
lineIndex: currentLineIndex
|
||||
)
|
||||
: nil
|
||||
let start = Date()
|
||||
var stylingResult = MarkdownTextStylingResult.empty
|
||||
var didRestoreVisibleOrigin = false
|
||||
|
|
@ -345,18 +366,28 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
selectedRange.location <= textView.string.utf16.count {
|
||||
textView.setSelectedRange(selectedRange)
|
||||
}
|
||||
didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView)
|
||||
}
|
||||
if stabilizationDecision.shouldStabilize {
|
||||
didRestoreVisibleOrigin = restoreViewportAnchor(viewportAnchor, in: textView)
|
||||
}
|
||||
|
||||
lastStyledText = textView.string
|
||||
lastStyledActiveLineIndex = activeLineIndex
|
||||
lastStyledEditableRegion = editableRegion
|
||||
recordViewportStabilityEvent(
|
||||
cause: cause,
|
||||
invalidationPlan: invalidationPlan,
|
||||
decision: stabilizationDecision,
|
||||
capturedAnchor: viewportAnchor != nil,
|
||||
restored: didRestoreVisibleOrigin
|
||||
)
|
||||
syncChecklistControls(
|
||||
in: textView,
|
||||
stylingResult: stylingResult,
|
||||
invalidationPlan: invalidationPlan,
|
||||
activeLineIndex: activeLineIndex
|
||||
)
|
||||
syncCodeBlockContainers(in: textView, editableRegion: editableRegion)
|
||||
parent.onRenderPass(EditorRenderPassMetric(
|
||||
reason: invalidationPlan.reason,
|
||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||
|
|
@ -376,20 +407,44 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
return EditableRegion.selection(textView.selectedRange(), in: currentLineIndex)
|
||||
}
|
||||
|
||||
func activateEditingPresentation(in textView: NSTextView) {
|
||||
func activateEditingPresentation(
|
||||
in textView: NSTextView,
|
||||
interactionKind: EditorInteractionKind = .programmatic
|
||||
) {
|
||||
lastUserInteractionKind = interactionKind
|
||||
guard !hasUserActivatedEditing else { return }
|
||||
hasUserActivatedEditing = true
|
||||
applyHybridAttributes(to: textView)
|
||||
applyHybridAttributes(to: textView, cause: .editingActivation(interactionKind))
|
||||
}
|
||||
|
||||
func setSelection(_ range: NSRange, in textView: NSTextView) {
|
||||
func setSelection(
|
||||
_ range: NSRange,
|
||||
in textView: NSTextView,
|
||||
interactionKind: EditorInteractionKind = .programmatic
|
||||
) {
|
||||
guard textView.selectedRange() != range else { return }
|
||||
performProgrammaticUpdate {
|
||||
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
||||
let shouldScroll = selectionNeedsScroll(range, in: textView)
|
||||
pendingSelectionInteractionKind = interactionKind
|
||||
textView.setSelectedRange(range)
|
||||
textView.scrollRangeToVisible(range)
|
||||
if shouldScroll {
|
||||
textView.scrollRangeToVisible(range)
|
||||
} else if let visibleOrigin {
|
||||
_ = scrollVisibleOrigin(visibleOrigin, in: textView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionNeedsScroll(_ range: NSRange, in textView: NSTextView) -> Bool {
|
||||
guard let point = textView.presentationAnchorPoint(at: range.location),
|
||||
let scrollView = textView.enclosingScrollView
|
||||
else { return true }
|
||||
|
||||
let visibleRect = scrollView.contentView.bounds.insetBy(dx: 0, dy: 12)
|
||||
return !visibleRect.contains(point)
|
||||
}
|
||||
|
||||
func performProgrammaticUpdate(_ updates: () -> Void) {
|
||||
programmaticUpdateDepth += 1
|
||||
defer {
|
||||
|
|
@ -426,6 +481,55 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
)
|
||||
}
|
||||
|
||||
private func viewportStabilizationDecision(
|
||||
cause: PresentationUpdateCause,
|
||||
invalidationPlan: EditorDirtyLineInvalidationPlan,
|
||||
editableRegion: EditableRegion,
|
||||
selectedRange: NSRange
|
||||
) -> ViewportStabilizationDecision {
|
||||
guard invalidationPlan.reason == .activeLineChange else {
|
||||
return ViewportStabilizationDecision(shouldStabilize: false, reason: "not-active-line-change")
|
||||
}
|
||||
|
||||
guard lastStyledEditableRegion != editableRegion else {
|
||||
return ViewportStabilizationDecision(shouldStabilize: false, reason: "no-editable-region-transition")
|
||||
}
|
||||
|
||||
guard selectedRange.length == 0 else {
|
||||
return ViewportStabilizationDecision(shouldStabilize: false, reason: "range-selection")
|
||||
}
|
||||
|
||||
switch cause {
|
||||
case .selectionChange(.keyboard), .editingActivation(.keyboard):
|
||||
return ViewportStabilizationDecision(shouldStabilize: false, reason: "native-keyboard-navigation")
|
||||
case .selectionChange(.programmatic), .editingActivation(.programmatic):
|
||||
return ViewportStabilizationDecision(shouldStabilize: false, reason: "programmatic-selection")
|
||||
case .selectionChange(.mouse), .editingActivation(.mouse), .focusChange:
|
||||
return ViewportStabilizationDecision(shouldStabilize: true, reason: "presentation-transition")
|
||||
case .selectionChange(.paste), .editingActivation(.paste), .sourceChange, .viewUpdate:
|
||||
return ViewportStabilizationDecision(shouldStabilize: false, reason: "non-presentation-navigation")
|
||||
}
|
||||
}
|
||||
|
||||
private func recordViewportStabilityEvent(
|
||||
cause: PresentationUpdateCause,
|
||||
invalidationPlan: EditorDirtyLineInvalidationPlan,
|
||||
decision: ViewportStabilizationDecision,
|
||||
capturedAnchor: Bool,
|
||||
restored: Bool
|
||||
) {
|
||||
viewportStabilityEvents.append(ViewportStabilityEvent(
|
||||
cause: cause.description,
|
||||
renderReason: "\(invalidationPlan.reason)",
|
||||
decision: decision.reason,
|
||||
capturedAnchor: capturedAnchor,
|
||||
restored: restored
|
||||
))
|
||||
if viewportStabilityEvents.count > 200 {
|
||||
viewportStabilityEvents.removeFirst(viewportStabilityEvents.count - 200)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncChecklistControls(
|
||||
in textView: NSTextView,
|
||||
stylingResult: MarkdownTextStylingResult,
|
||||
|
|
@ -483,6 +587,34 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private func syncCodeBlockContainers(in textView: NSTextView, editableRegion: EditableRegion) {
|
||||
guard let textView = textView as? EditorTextView else { return }
|
||||
let codeBlocks = DocumentPresentationState.renderedCodeBlocks(
|
||||
in: currentLineIndex,
|
||||
editableRegion: editableRegion
|
||||
)
|
||||
textView.codeBlockContainers = codeBlocks.map {
|
||||
CodeBlockContainerPresentation(
|
||||
codeBlock: $0,
|
||||
languageLabel: codeBlockLanguageLabel(for: $0, in: textView.string)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func codeBlockLanguageLabel(
|
||||
for codeBlock: RenderedCodeBlockElement,
|
||||
in source: String
|
||||
) -> String {
|
||||
guard let languageRange = codeBlock.languageRange,
|
||||
languageRange.upperBound <= source.utf16.count
|
||||
else { return "Text" }
|
||||
|
||||
let language = (source as NSString).substring(with: languageRange)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !language.isEmpty else { return "Text" }
|
||||
return language.prefix(1).uppercased() + language.dropFirst().lowercased()
|
||||
}
|
||||
|
||||
private func toggleTask(_ task: RenderedTaskElement, in textView: NSTextView) {
|
||||
guard task.checkboxRange.upperBound <= textView.string.utf16.count else { return }
|
||||
|
||||
|
|
@ -547,11 +679,27 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
checklistButtons.removeAll()
|
||||
}
|
||||
|
||||
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
|
||||
guard let origin,
|
||||
let scrollView = textView.enclosingScrollView
|
||||
else { return false }
|
||||
private func restoreViewportAnchor(
|
||||
_ anchor: ViewportPresentationAnchor?,
|
||||
in textView: NSTextView
|
||||
) -> Bool {
|
||||
guard let anchor,
|
||||
let point = textView.presentationAnchorPoint(at: anchor.characterLocation)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return scrollVisibleOrigin(
|
||||
NSPoint(
|
||||
x: anchor.visibleOrigin.x,
|
||||
y: point.y - anchor.viewportOffsetY
|
||||
),
|
||||
in: textView
|
||||
)
|
||||
}
|
||||
|
||||
private func scrollVisibleOrigin(_ origin: NSPoint, in textView: NSTextView) -> Bool {
|
||||
guard let scrollView = textView.enclosingScrollView else { return false }
|
||||
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
||||
let maxX = max(0, textView.bounds.width - scrollView.contentView.bounds.width)
|
||||
let clampedOrigin = NSPoint(
|
||||
|
|
@ -565,9 +713,119 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private enum EditorInteractionKind: String {
|
||||
case mouse
|
||||
case keyboard
|
||||
case paste
|
||||
case programmatic
|
||||
}
|
||||
|
||||
private enum PresentationUpdateCause: CustomStringConvertible {
|
||||
case editingActivation(EditorInteractionKind)
|
||||
case selectionChange(EditorInteractionKind)
|
||||
case focusChange
|
||||
case sourceChange
|
||||
case viewUpdate
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .editingActivation(let kind):
|
||||
return "editingActivation:\(kind.rawValue)"
|
||||
case .selectionChange(let kind):
|
||||
return "selectionChange:\(kind.rawValue)"
|
||||
case .focusChange:
|
||||
return "focusChange"
|
||||
case .sourceChange:
|
||||
return "sourceChange"
|
||||
case .viewUpdate:
|
||||
return "viewUpdate"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ViewportStabilizationDecision {
|
||||
var shouldStabilize: Bool
|
||||
var reason: String
|
||||
}
|
||||
|
||||
private struct ViewportStabilityEvent: Hashable, Sendable {
|
||||
var cause: String
|
||||
var renderReason: String
|
||||
var decision: String
|
||||
var capturedAnchor: Bool
|
||||
var restored: Bool
|
||||
}
|
||||
|
||||
private struct CodeBlockContainerPresentation: Equatable {
|
||||
var codeBlock: RenderedCodeBlockElement
|
||||
var languageLabel: String
|
||||
}
|
||||
|
||||
private struct ViewportPresentationAnchor {
|
||||
var characterLocation: Int
|
||||
var visibleOrigin: NSPoint
|
||||
var viewportOffsetY: CGFloat
|
||||
|
||||
static func capture(
|
||||
in textView: NSTextView,
|
||||
selectedRange: NSRange,
|
||||
lineIndex: DocumentLineIndex
|
||||
) -> ViewportPresentationAnchor? {
|
||||
guard let scrollView = textView.enclosingScrollView,
|
||||
textView.string.utf16.count > 0
|
||||
else { return nil }
|
||||
|
||||
let location = presentationAnchorLocation(
|
||||
for: selectedRange,
|
||||
lineIndex: lineIndex,
|
||||
textLength: textView.string.utf16.count
|
||||
)
|
||||
guard let point = textView.presentationAnchorPoint(at: location) else { return nil }
|
||||
|
||||
let visibleOrigin = scrollView.contentView.bounds.origin
|
||||
return ViewportPresentationAnchor(
|
||||
characterLocation: location,
|
||||
visibleOrigin: visibleOrigin,
|
||||
viewportOffsetY: point.y - visibleOrigin.y
|
||||
)
|
||||
}
|
||||
|
||||
private static func presentationAnchorLocation(
|
||||
for selectedRange: NSRange,
|
||||
lineIndex: DocumentLineIndex,
|
||||
textLength: Int
|
||||
) -> Int {
|
||||
let selectedLocation = min(max(0, selectedRange.location), max(0, textLength - 1))
|
||||
let lineNumber = lineIndex.lineIndex(containing: selectedLocation)
|
||||
guard let line = lineIndex.editorLine(at: lineNumber, activeLineIndex: lineNumber) else {
|
||||
return selectedLocation
|
||||
}
|
||||
|
||||
let renderPlan = HybridMarkdownLineRenderer().renderPlan(for: line)
|
||||
switch renderPlan.kind {
|
||||
case .heading(_, _, let textRange):
|
||||
return selectedLocation < textRange.location ? textRange.location : selectedLocation
|
||||
case .taskList(_, _, let contentRange, _, _):
|
||||
return selectedLocation < contentRange.location ? contentRange.location : selectedLocation
|
||||
case .unorderedList(_, let contentRange, _),
|
||||
.orderedList(_, let contentRange, _),
|
||||
.blockquote(_, let contentRange):
|
||||
return selectedLocation < contentRange.location ? contentRange.location : selectedLocation
|
||||
default:
|
||||
return selectedLocation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class EditorTextView: NSTextView {
|
||||
var onFocusStateChange: ((NSTextView) -> Void)?
|
||||
var onUserEditingInteraction: ((NSTextView) -> Void)?
|
||||
var onUserEditingInteraction: ((NSTextView, EditorInteractionKind) -> Void)?
|
||||
var codeBlockContainers: [CodeBlockContainerPresentation] = [] {
|
||||
didSet {
|
||||
guard oldValue != codeBlockContainers else { return }
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
|
||||
override var acceptsFirstResponder: Bool {
|
||||
true
|
||||
|
|
@ -590,19 +848,128 @@ private final class EditorTextView: NSTextView {
|
|||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
onUserEditingInteraction?(self)
|
||||
onUserEditingInteraction?(self, .mouse)
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
onUserEditingInteraction?(self)
|
||||
onUserEditingInteraction?(self, .keyboard)
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
override func paste(_ sender: Any?) {
|
||||
onUserEditingInteraction?(self)
|
||||
onUserEditingInteraction?(self, .paste)
|
||||
super.paste(sender)
|
||||
}
|
||||
|
||||
func invalidateCodeBlockContainers() {
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
func codeBlockContainerFrame(containing lineIndex: Int) -> NSRect? {
|
||||
guard let container = codeBlockContainers.first(where: { $0.codeBlock.lineIndexes.contains(lineIndex) }) else {
|
||||
return nil
|
||||
}
|
||||
return codeBlockFrame(for: container)
|
||||
}
|
||||
|
||||
override func drawBackground(in rect: NSRect) {
|
||||
super.drawBackground(in: rect)
|
||||
drawCodeBlockContainers(in: rect)
|
||||
}
|
||||
|
||||
private func drawCodeBlockContainers(in dirtyRect: NSRect) {
|
||||
for container in codeBlockContainers {
|
||||
guard let frame = codeBlockFrame(for: container),
|
||||
frame.intersects(dirtyRect)
|
||||
else { continue }
|
||||
|
||||
drawCodeBlockContainer(container, in: frame)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawCodeBlockContainer(
|
||||
_ container: CodeBlockContainerPresentation,
|
||||
in frame: NSRect
|
||||
) {
|
||||
let cornerRadius: CGFloat = 8
|
||||
let headerHeight = min(30, max(24, frame.height * 0.32))
|
||||
let headerRect = NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: headerHeight)
|
||||
|
||||
let bodyPath = NSBezierPath(roundedRect: frame, xRadius: cornerRadius, yRadius: cornerRadius)
|
||||
NSColor.controlAccentColor.withAlphaComponent(0.075).setFill()
|
||||
bodyPath.fill()
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
bodyPath.addClip()
|
||||
NSColor.controlAccentColor.withAlphaComponent(0.12).setFill()
|
||||
headerRect.fill()
|
||||
NSColor.separatorColor.withAlphaComponent(0.35).setStroke()
|
||||
NSBezierPath.strokeLine(
|
||||
from: NSPoint(x: headerRect.minX, y: headerRect.maxY),
|
||||
to: NSPoint(x: headerRect.maxX, y: headerRect.maxY)
|
||||
)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
NSColor.separatorColor.withAlphaComponent(0.35).setStroke()
|
||||
bodyPath.lineWidth = 1
|
||||
bodyPath.stroke()
|
||||
|
||||
let labelAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold),
|
||||
.foregroundColor: NSColor.secondaryLabelColor
|
||||
]
|
||||
let label = NSAttributedString(string: container.languageLabel, attributes: labelAttributes)
|
||||
let labelRect = NSRect(
|
||||
x: headerRect.minX + 14,
|
||||
y: headerRect.midY - ceil(label.size().height / 2),
|
||||
width: max(0, headerRect.width - 28),
|
||||
height: label.size().height
|
||||
)
|
||||
label.draw(in: labelRect)
|
||||
}
|
||||
|
||||
private func codeBlockFrame(for container: CodeBlockContainerPresentation) -> NSRect? {
|
||||
guard let layoutManager,
|
||||
let textContainer,
|
||||
textStorage?.length ?? 0 > 0
|
||||
else { return nil }
|
||||
|
||||
let textLength = string.utf16.count
|
||||
let sourceRange = NSRange(
|
||||
location: min(container.codeBlock.sourceRange.location, max(0, textLength - 1)),
|
||||
length: min(
|
||||
container.codeBlock.sourceRange.length,
|
||||
max(0, textLength - container.codeBlock.sourceRange.location)
|
||||
)
|
||||
)
|
||||
guard sourceRange.location < textLength else { return nil }
|
||||
|
||||
layoutManager.ensureLayout(forCharacterRange: sourceRange)
|
||||
let firstCharacterRange = NSRange(location: sourceRange.location, length: 1)
|
||||
let lastLocation = max(sourceRange.location, min(sourceRange.upperBound - 1, textLength - 1))
|
||||
let lastCharacterRange = NSRange(location: lastLocation, length: 1)
|
||||
let firstGlyphRange = layoutManager.glyphRange(
|
||||
forCharacterRange: firstCharacterRange,
|
||||
actualCharacterRange: nil
|
||||
)
|
||||
let lastGlyphRange = layoutManager.glyphRange(
|
||||
forCharacterRange: lastCharacterRange,
|
||||
actualCharacterRange: nil
|
||||
)
|
||||
guard firstGlyphRange.length > 0, lastGlyphRange.length > 0 else { return nil }
|
||||
|
||||
let firstFragment = layoutManager.lineFragmentRect(forGlyphAt: firstGlyphRange.location, effectiveRange: nil)
|
||||
let lastFragment = layoutManager.lineFragmentRect(forGlyphAt: lastGlyphRange.location, effectiveRange: nil)
|
||||
let origin = textContainerOrigin
|
||||
let horizontalInset: CGFloat = 4
|
||||
return NSRect(
|
||||
x: origin.x + horizontalInset,
|
||||
y: origin.y + firstFragment.minY,
|
||||
width: max(0, textContainer.containerSize.width - horizontalInset * 2),
|
||||
height: max(0, lastFragment.maxY - firstFragment.minY)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ChecklistOverlayButton: NSButton {
|
||||
|
|
@ -746,10 +1113,10 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
self.textView.isVerticallyResizable = true
|
||||
self.textView.isHorizontallyResizable = false
|
||||
self.textView.onFocusStateChange = { [weak coordinator] textView in
|
||||
coordinator?.applyHybridAttributes(to: textView)
|
||||
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
||||
}
|
||||
self.textView.onUserEditingInteraction = { [weak coordinator] textView in
|
||||
coordinator?.activateEditingPresentation(in: textView)
|
||||
self.textView.onUserEditingInteraction = { [weak coordinator] textView, interactionKind in
|
||||
coordinator?.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||
}
|
||||
self.textView.string = source
|
||||
self.textView.delegate = coordinator
|
||||
|
|
@ -774,6 +1141,7 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
self.scrollView.editorTextView = textView
|
||||
self.scrollView.onEditorLayoutInvalidated = { [weak coordinator] textView in
|
||||
coordinator?.syncChecklistControlFrames(in: textView)
|
||||
(textView as? EditorTextView)?.invalidateCodeBlockContainers()
|
||||
}
|
||||
self.scrollView.updateEditorInsets()
|
||||
|
||||
|
|
@ -799,8 +1167,20 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
}
|
||||
|
||||
public func setSelection(_ range: NSRange) {
|
||||
coordinator.activateEditingPresentation(in: textView)
|
||||
coordinator.setSelection(range, in: textView)
|
||||
setSelection(range, interactionKind: .programmatic)
|
||||
}
|
||||
|
||||
public func setSelectionByKeyboard(_ range: NSRange) {
|
||||
setSelection(range, interactionKind: .keyboard)
|
||||
}
|
||||
|
||||
public func setSelectionByMouse(_ range: NSRange) {
|
||||
setSelection(range, interactionKind: .mouse)
|
||||
}
|
||||
|
||||
private func setSelection(_ range: NSRange, interactionKind: EditorInteractionKind) {
|
||||
coordinator.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||
coordinator.setSelection(range, in: textView, interactionKind: interactionKind)
|
||||
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
|
||||
syncState()
|
||||
}
|
||||
|
|
@ -821,6 +1201,18 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
syncState()
|
||||
}
|
||||
|
||||
public func scrollViewport(toY y: CGFloat) {
|
||||
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
||||
let target = NSPoint(x: 0, y: max(0, min(y, maxY)))
|
||||
scrollView.contentView.scroll(to: target)
|
||||
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||
syncState()
|
||||
}
|
||||
|
||||
public func viewportOrigin() -> CGPoint {
|
||||
scrollView.contentView.bounds.origin
|
||||
}
|
||||
|
||||
public func headingMarkerIsHidden() -> Bool {
|
||||
isHidden(at: 0)
|
||||
}
|
||||
|
|
@ -849,6 +1241,24 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
return CGPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
|
||||
}
|
||||
|
||||
public func viewportPoint(for text: String) -> CGPoint? {
|
||||
guard let point = point(for: text) else { return nil }
|
||||
let origin = viewportOrigin()
|
||||
return CGPoint(x: point.x - origin.x, y: point.y - origin.y)
|
||||
}
|
||||
|
||||
public func viewportStabilityEventDescriptions() -> [String] {
|
||||
coordinator.viewportStabilityEvents.map {
|
||||
[
|
||||
"cause=\($0.cause)",
|
||||
"reason=\($0.renderReason)",
|
||||
"decision=\($0.decision)",
|
||||
"captured=\($0.capturedAnchor)",
|
||||
"restored=\($0.restored)"
|
||||
].joined(separator: "|")
|
||||
}
|
||||
}
|
||||
|
||||
public func presentationSignature() -> String {
|
||||
guard let storage = textView.textStorage else { return "" }
|
||||
return MarkdownPresentationSnapshot.make(
|
||||
|
|
@ -878,6 +1288,18 @@ public final class HybridMarkdownLiveEditorHarness {
|
|||
return labelFrame.minX - buttonFrame.maxX
|
||||
}
|
||||
|
||||
public func codeBlockContainerCount() -> Int {
|
||||
textView.codeBlockContainers.count
|
||||
}
|
||||
|
||||
public func codeBlockContainerLabel(containing lineIndex: Int) -> String? {
|
||||
textView.codeBlockContainers.first { $0.codeBlock.lineIndexes.contains(lineIndex) }?.languageLabel
|
||||
}
|
||||
|
||||
public func codeBlockContainerFrame(containing lineIndex: Int) -> CGRect? {
|
||||
textView.codeBlockContainerFrame(containing: lineIndex)
|
||||
}
|
||||
|
||||
public func selectedRange() -> NSRange {
|
||||
textView.selectedRange()
|
||||
}
|
||||
|
|
@ -1135,6 +1557,7 @@ struct MarkdownTextStylingResult {
|
|||
var styledLineCount: Int
|
||||
var styledLineIndexes: [Int]
|
||||
var renderedTasks: [RenderedTaskElement]
|
||||
var renderedCodeBlocks: [RenderedCodeBlockElement]
|
||||
var editableRegion: EditableRegion
|
||||
|
||||
static let empty = MarkdownTextStylingResult(
|
||||
|
|
@ -1142,6 +1565,7 @@ struct MarkdownTextStylingResult {
|
|||
styledLineCount: 0,
|
||||
styledLineIndexes: [],
|
||||
renderedTasks: [],
|
||||
renderedCodeBlocks: [],
|
||||
editableRegion: .none()
|
||||
)
|
||||
}
|
||||
|
|
@ -1176,6 +1600,7 @@ enum MarkdownTextStyler {
|
|||
styledLineCount: lineIndex.lineCount,
|
||||
styledLineIndexes: Array(0..<lineIndex.lineCount),
|
||||
renderedTasks: [],
|
||||
renderedCodeBlocks: [],
|
||||
editableRegion: resolvedEditableRegion
|
||||
)
|
||||
}
|
||||
|
|
@ -1227,6 +1652,7 @@ enum MarkdownTextStyler {
|
|||
styledLineCount: styledLineCount,
|
||||
styledLineIndexes: styledLineIndexes,
|
||||
renderedTasks: presentationState.renderedTasks,
|
||||
renderedCodeBlocks: presentationState.renderedCodeBlocks,
|
||||
editableRegion: presentationState.editableRegion
|
||||
)
|
||||
}
|
||||
|
|
@ -1338,22 +1764,11 @@ enum MarkdownTextStyler {
|
|||
.foregroundColor: secondaryTextColor
|
||||
], range: contentRange)
|
||||
}
|
||||
case .fencedCodeFence(let markerRange, let languageRange):
|
||||
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
|
||||
if let languageRange {
|
||||
hideSyntax(
|
||||
in: textStorage,
|
||||
range: NSRange(location: markerRange.location, length: languageRange.location - markerRange.location)
|
||||
)
|
||||
textStorage.addAttributes([
|
||||
.foregroundColor: accentColor,
|
||||
.font: monospacedFont(size: 13, weight: .semibold)
|
||||
], range: languageRange)
|
||||
} else {
|
||||
hideSyntax(in: textStorage, range: line.range)
|
||||
}
|
||||
case .fencedCodeFence(_, _, let role):
|
||||
textStorage.addAttributes(codeBlockFenceAttributes(role: role), range: paragraphRange)
|
||||
hideSyntax(in: textStorage, range: line.range)
|
||||
case .codeBlockContent(let language):
|
||||
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
|
||||
textStorage.addAttributes(codeBlockContentAttributes(), range: paragraphRange)
|
||||
styleCodeSyntax(
|
||||
in: textStorage,
|
||||
line: line,
|
||||
|
|
@ -1599,21 +2014,39 @@ enum MarkdownTextStyler {
|
|||
return paragraph
|
||||
}
|
||||
|
||||
private static func codeBlockParagraphStyle() -> NSMutableParagraphStyle {
|
||||
private static func codeBlockContentParagraphStyle() -> NSMutableParagraphStyle {
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 3
|
||||
paragraph.paragraphSpacing = 4
|
||||
paragraph.paragraphSpacingBefore = 2
|
||||
paragraph.firstLineHeadIndent = 14
|
||||
paragraph.headIndent = 14
|
||||
paragraph.paragraphSpacing = 0
|
||||
paragraph.paragraphSpacingBefore = 0
|
||||
paragraph.firstLineHeadIndent = 18
|
||||
paragraph.headIndent = 18
|
||||
return paragraph
|
||||
}
|
||||
|
||||
private static func codeBlockAttributes(accentColor: PlatformColor) -> [NSAttributedString.Key: Any] {
|
||||
private static func codeBlockFenceParagraphStyle(role: FencedCodeFenceRole) -> NSMutableParagraphStyle {
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 0
|
||||
paragraph.paragraphSpacing = role == .closing ? 10 : 0
|
||||
paragraph.paragraphSpacingBefore = role == .opening ? 8 : 0
|
||||
paragraph.minimumLineHeight = role == .opening ? 30 : 12
|
||||
paragraph.maximumLineHeight = role == .opening ? 30 : 12
|
||||
paragraph.firstLineHeadIndent = 18
|
||||
paragraph.headIndent = 18
|
||||
return paragraph
|
||||
}
|
||||
|
||||
private static func codeBlockContentAttributes() -> [NSAttributedString.Key: Any] {
|
||||
[
|
||||
.font: monospacedFont(size: 15, weight: .regular),
|
||||
.backgroundColor: accentColor.withAlphaComponent(0.08),
|
||||
.paragraphStyle: codeBlockParagraphStyle()
|
||||
.paragraphStyle: codeBlockContentParagraphStyle()
|
||||
]
|
||||
}
|
||||
|
||||
private static func codeBlockFenceAttributes(role: FencedCodeFenceRole) -> [NSAttributedString.Key: Any] {
|
||||
[
|
||||
.font: monospacedFont(size: 15, weight: .regular),
|
||||
.paragraphStyle: codeBlockFenceParagraphStyle(role: role)
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1756,6 +2189,30 @@ private extension NSRange {
|
|||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private extension NSTextView {
|
||||
func presentationAnchorPoint(at location: Int) -> NSPoint? {
|
||||
guard let layoutManager,
|
||||
string.utf16.count > 0
|
||||
else { return nil }
|
||||
|
||||
let clampedLocation = min(max(0, location), string.utf16.count - 1)
|
||||
let characterRange = NSRange(location: clampedLocation, length: 1)
|
||||
layoutManager.ensureLayout(forCharacterRange: characterRange)
|
||||
let glyphRange = layoutManager.glyphRange(
|
||||
forCharacterRange: characterRange,
|
||||
actualCharacterRange: nil
|
||||
)
|
||||
guard glyphRange.length > 0 else { return nil }
|
||||
|
||||
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
|
||||
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
|
||||
let origin = textContainerOrigin
|
||||
return NSPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var platformTextBackground: Color {
|
||||
#if os(macOS)
|
||||
Color(nsColor: .textBackgroundColor)
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@ public enum HybridMarkdownLineKind: Hashable, Sendable {
|
|||
checked: Bool,
|
||||
nestingLevel: Int
|
||||
)
|
||||
case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?)
|
||||
case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?, role: FencedCodeFenceRole)
|
||||
case codeBlockContent(language: String?)
|
||||
case tableRow(cellRanges: [NSRange], separatorRanges: [NSRange], isDivider: Bool)
|
||||
}
|
||||
|
||||
public enum FencedCodeFenceRole: String, Hashable, Sendable {
|
||||
case opening
|
||||
case closing
|
||||
}
|
||||
|
||||
public enum HybridMarkdownSpanKind: Hashable, Sendable {
|
||||
case bold
|
||||
case italic
|
||||
|
|
@ -68,12 +73,13 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
|||
let nonCodeKind = lineKind(for: line)
|
||||
let kind: HybridMarkdownLineKind
|
||||
|
||||
if case .fencedCodeFence(_, let languageRange) = nonCodeKind {
|
||||
kind = nonCodeKind
|
||||
if case .fencedCodeFence(let markerRange, let languageRange, _) = nonCodeKind {
|
||||
if isInCodeBlock {
|
||||
kind = .fencedCodeFence(markerRange: markerRange, languageRange: languageRange, role: .closing)
|
||||
isInCodeBlock = false
|
||||
codeBlockLanguage = nil
|
||||
} else {
|
||||
kind = .fencedCodeFence(markerRange: markerRange, languageRange: languageRange, role: .opening)
|
||||
isInCodeBlock = true
|
||||
codeBlockLanguage = language(in: line, range: languageRange)
|
||||
}
|
||||
|
|
@ -102,8 +108,12 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
|||
let nonCodeKind = lineKind(for: line)
|
||||
let kind: HybridMarkdownLineKind
|
||||
|
||||
if case .fencedCodeFence = nonCodeKind {
|
||||
kind = nonCodeKind
|
||||
if case .fencedCodeFence(let markerRange, let languageRange, _) = nonCodeKind {
|
||||
kind = .fencedCodeFence(
|
||||
markerRange: markerRange,
|
||||
languageRange: languageRange,
|
||||
role: fencedCodeFenceRole(for: line.index, in: lineIndex, activeLineIndex: activeLineIndex)
|
||||
)
|
||||
} else if let context = fencedCodeBlockContext(
|
||||
containing: line.index,
|
||||
in: lineIndex,
|
||||
|
|
@ -277,7 +287,8 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
|||
location: line.range.location + match.range(at: 2).location,
|
||||
length: match.range(at: 2).length
|
||||
),
|
||||
languageRange: languageRange
|
||||
languageRange: languageRange,
|
||||
role: .opening
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -529,7 +540,7 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
|||
var lineCursor = 0
|
||||
while lineCursor < lineNumber {
|
||||
if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex),
|
||||
case .fencedCodeFence(_, let languageRange) = lineKind(for: line) {
|
||||
case .fencedCodeFence(_, let languageRange, _) = lineKind(for: line) {
|
||||
if isInside {
|
||||
isInside = false
|
||||
language = nil
|
||||
|
|
@ -543,6 +554,25 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
|||
return isInside ? CodeBlockContext(language: language) : nil
|
||||
}
|
||||
|
||||
private func fencedCodeFenceRole(
|
||||
for lineNumber: Int,
|
||||
in lineIndex: DocumentLineIndex,
|
||||
activeLineIndex: Int
|
||||
) -> FencedCodeFenceRole {
|
||||
guard lineNumber > 0 else { return .opening }
|
||||
|
||||
var isInside = false
|
||||
var lineCursor = 0
|
||||
while lineCursor < lineNumber {
|
||||
if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex),
|
||||
case .fencedCodeFence = lineKind(for: line) {
|
||||
isInside.toggle()
|
||||
}
|
||||
lineCursor += 1
|
||||
}
|
||||
return isInside ? .closing : .opening
|
||||
}
|
||||
|
||||
private func language(in line: EditorLine, range: NSRange?) -> String? {
|
||||
guard let range else { return nil }
|
||||
let localRange = NSRange(location: range.location - line.range.location, length: range.length)
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ final class DocumentPresentationStateTests: XCTestCase {
|
|||
XCTAssertEqual(region.lineIndexes, [1, 2, 3])
|
||||
XCTAssertEqual(presentation.lines.filter { $0.state == .source }.map(\.line.index), [1, 2, 3])
|
||||
XCTAssertEqual(presentation.lines.filter { $0.state == .rendered }.map(\.line.index), [0, 4])
|
||||
XCTAssertTrue(presentation.renderedCodeBlocks.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -212,11 +212,15 @@ final class EditorStateTests: XCTestCase {
|
|||
plans[0].kind,
|
||||
.fencedCodeFence(
|
||||
markerRange: NSRange(location: 0, length: 3),
|
||||
languageRange: NSRange(location: 3, length: 5)
|
||||
languageRange: NSRange(location: 3, length: 5),
|
||||
role: .opening
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(plans[1].kind, .codeBlockContent(language: "swift"))
|
||||
XCTAssertEqual(plans[2].kind, .fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil))
|
||||
XCTAssertEqual(
|
||||
plans[2].kind,
|
||||
.fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil, role: .closing)
|
||||
)
|
||||
}
|
||||
|
||||
func testHybridRendererSupportsMarkdownTables() {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,123 @@ final class HybridMarkdownLiveEditorHarnessTests: XCTestCase {
|
|||
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```swift").location))
|
||||
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location))
|
||||
}
|
||||
|
||||
func testLiveRenderedCodeBlockUsesSingleContainerPresentation() throws {
|
||||
let source = """
|
||||
Intro
|
||||
```swift
|
||||
struct Example {
|
||||
let value = 42
|
||||
}
|
||||
```
|
||||
Outro
|
||||
"""
|
||||
let nsSource = source as NSString
|
||||
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
||||
|
||||
harness.simulateLaunchFirstResponder()
|
||||
|
||||
XCTAssertEqual(harness.codeBlockContainerCount(), 1)
|
||||
XCTAssertEqual(harness.codeBlockContainerLabel(containing: 2), "Swift")
|
||||
let initialFrame = try XCTUnwrap(harness.codeBlockContainerFrame(containing: 2))
|
||||
XCTAssertGreaterThan(initialFrame.height, 80)
|
||||
XCTAssertGreaterThan(initialFrame.width, 500)
|
||||
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```swift").location))
|
||||
XCTAssertTrue(harness.characterIsHidden(at: nsSource.range(of: "```", options: .backwards).location))
|
||||
|
||||
harness.setSelection(NSRange(location: nsSource.range(of: "value").location, length: 0))
|
||||
|
||||
XCTAssertEqual(harness.codeBlockContainerCount(), 0)
|
||||
XCTAssertFalse(harness.characterIsHidden(at: nsSource.range(of: "```swift").location))
|
||||
|
||||
harness.setSelection(NSRange(location: nsSource.range(of: "Intro").location, length: 0))
|
||||
|
||||
XCTAssertEqual(harness.codeBlockContainerCount(), 1)
|
||||
let restoredFrame = try XCTUnwrap(harness.codeBlockContainerFrame(containing: 2))
|
||||
XCTAssertEqual(initialFrame.origin.x, restoredFrame.origin.x, accuracy: 0.001)
|
||||
XCTAssertEqual(initialFrame.size.width, restoredFrame.size.width, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testHeadingTransitionKeepsEditedContentVisuallyAnchored() throws {
|
||||
let intro = (0..<24).map { "Intro paragraph \($0)" }.joined(separator: "\n")
|
||||
let outro = (0..<24).map { "Outro paragraph \($0)" }.joined(separator: "\n")
|
||||
let source = """
|
||||
\(intro)
|
||||
# Navigation Checklist
|
||||
Body text below the heading.
|
||||
\(outro)
|
||||
"""
|
||||
let nsSource = source as NSString
|
||||
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
||||
harness.simulateLaunchFirstResponder()
|
||||
let headingPoint = try XCTUnwrap(harness.point(for: "Navigation"))
|
||||
harness.scrollViewport(toY: headingPoint.y - 160)
|
||||
let renderedViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "Navigation"))
|
||||
|
||||
harness.setSelectionByMouse(NSRange(location: nsSource.range(of: "Navigation").location, length: 0))
|
||||
|
||||
let sourceViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "Navigation"))
|
||||
XCTAssertEqual(sourceViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
||||
|
||||
harness.simulateFocusAway()
|
||||
|
||||
let restoredViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "Navigation"))
|
||||
XCTAssertEqual(restoredViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testCodeBlockTransitionKeepsEditedContentVisuallyAnchored() throws {
|
||||
let intro = (0..<24).map { "Intro paragraph \($0)" }.joined(separator: "\n")
|
||||
let outro = (0..<24).map { "Outro paragraph \($0)" }.joined(separator: "\n")
|
||||
let source = """
|
||||
\(intro)
|
||||
```swift
|
||||
struct Example {
|
||||
let value = 42
|
||||
}
|
||||
```
|
||||
\(outro)
|
||||
"""
|
||||
let nsSource = source as NSString
|
||||
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
||||
harness.simulateLaunchFirstResponder()
|
||||
let codePoint = try XCTUnwrap(harness.point(for: "value"))
|
||||
harness.scrollViewport(toY: codePoint.y - 180)
|
||||
let renderedViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "value"))
|
||||
|
||||
harness.setSelectionByMouse(NSRange(location: nsSource.range(of: "value").location, length: 0))
|
||||
|
||||
let sourceViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "value"))
|
||||
XCTAssertEqual(sourceViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
||||
|
||||
harness.simulateFocusAway()
|
||||
|
||||
let restoredViewportPoint = try XCTUnwrap(harness.viewportPoint(for: "value"))
|
||||
XCTAssertEqual(restoredViewportPoint.y, renderedViewportPoint.y, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testKeyboardNavigationDoesNotRunViewportStabilization() throws {
|
||||
let intro = (0..<24).map { "Intro paragraph \($0)" }.joined(separator: "\n")
|
||||
let outro = (0..<24).map { "Outro paragraph \($0)" }.joined(separator: "\n")
|
||||
let source = """
|
||||
\(intro)
|
||||
# Navigation Checklist
|
||||
Body text below the heading.
|
||||
\(outro)
|
||||
"""
|
||||
let nsSource = source as NSString
|
||||
let harness = HybridMarkdownLiveEditorHarness(source: source, initialWidth: 700)
|
||||
harness.simulateLaunchFirstResponder()
|
||||
let headingPoint = try XCTUnwrap(harness.point(for: "Navigation"))
|
||||
harness.scrollViewport(toY: headingPoint.y - 160)
|
||||
|
||||
harness.setSelectionByKeyboard(NSRange(location: nsSource.range(of: "Navigation").location, length: 0))
|
||||
|
||||
XCTAssertTrue(harness.viewportStabilityEventDescriptions().contains {
|
||||
$0.contains("cause=selectionChange:keyboard")
|
||||
&& $0.contains("decision=native-keyboard-navigation")
|
||||
&& $0.contains("restored=false")
|
||||
})
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ final class MarkdownTextStylerRenderingTests: XCTestCase {
|
|||
let closingFence = nsSource.range(of: "```", options: .backwards)
|
||||
|
||||
XCTAssertTrue(isHidden(storage, at: openingFence.location))
|
||||
XCTAssertFalse(isHidden(storage, at: language.location))
|
||||
XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
||||
XCTAssertTrue(isHidden(storage, at: language.location))
|
||||
XCTAssertNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
||||
XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true)
|
||||
XCTAssertGreaterThan(paragraphStyle(in: storage, at: code.location).headIndent, 0)
|
||||
XCTAssertEqual(font(in: storage, at: keyword.location).fontDescriptor.symbolicTraits.contains(.bold), true)
|
||||
|
|
@ -88,7 +88,7 @@ final class MarkdownTextStylerRenderingTests: XCTestCase {
|
|||
)
|
||||
|
||||
let code = (source as NSString).range(of: "let value = 42")
|
||||
XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
||||
XCTAssertNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
||||
XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ Success Criteria:
|
|||
|
||||
# Milestone 3 — Rendering Engine
|
||||
|
||||
Status: Planned
|
||||
Status: Complete
|
||||
|
||||
Goal:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue