fix(ui): verify live editor presentation path

This commit is contained in:
Feror 2026-06-01 09:40:19 +02:00
parent f9530a9164
commit 75da8d10e1
2 changed files with 314 additions and 3 deletions

View file

@ -203,11 +203,14 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
textView.isVerticallyResizable = true textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false textView.isHorizontallyResizable = false
textView.string = text
textView.delegate = context.coordinator textView.delegate = context.coordinator
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
coordinator?.applyHybridAttributes(to: textView) coordinator?.applyHybridAttributes(to: textView)
} }
textView.string = text textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView in
coordinator?.activateEditingPresentation(in: textView)
}
textView.isRichText = false textView.isRichText = false
textView.isEditable = true textView.isEditable = true
textView.isSelectable = true textView.isSelectable = true
@ -261,6 +264,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
private var lastStyledText: String? private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int? private var lastStyledActiveLineIndex: Int?
private var pendingEdit: DocumentLineIndexEdit? private var pendingEdit: DocumentLineIndexEdit?
private var hasUserActivatedEditing = false
private var checklistButtons: [Int: ChecklistOverlayButton] = [:] private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
init(_ parent: NativeMarkdownTextView) { init(_ parent: NativeMarkdownTextView) {
@ -284,6 +288,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
guard !isPerformingProgrammaticUpdate else { return } guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return } guard let textView = notification.object as? NSTextView else { return }
hasUserActivatedEditing = true
let selection = EditorSelection(range: textView.selectedRange()) let selection = EditorSelection(range: textView.selectedRange())
let edit = pendingEdit let edit = pendingEdit
if let edit { if let edit {
@ -358,10 +363,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
} }
private func presentationActiveLineIndex(in textView: NSTextView) -> Int { private func presentationActiveLineIndex(in textView: NSTextView) -> Int {
guard textView.window?.firstResponder === textView else { return -1 } guard hasUserActivatedEditing,
textView.window?.firstResponder === textView
else { return -1 }
return currentLineIndex.lineIndex(containing: textView.selectedRange().location) return currentLineIndex.lineIndex(containing: textView.selectedRange().location)
} }
func activateEditingPresentation(in textView: NSTextView) {
guard !hasUserActivatedEditing else { return }
hasUserActivatedEditing = true
applyHybridAttributes(to: textView)
}
func setSelection(_ range: NSRange, in textView: NSTextView) { func setSelection(_ range: NSRange, in textView: NSTextView) {
guard textView.selectedRange() != range else { return } guard textView.selectedRange() != range else { return }
performProgrammaticUpdate { performProgrammaticUpdate {
@ -381,6 +394,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
func invalidateStylingCache() { func invalidateStylingCache() {
lastStyledText = nil lastStyledText = nil
lastStyledActiveLineIndex = nil lastStyledActiveLineIndex = nil
hasUserActivatedEditing = false
removeChecklistControls() removeChecklistControls()
} }
@ -531,6 +545,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
private final class EditorTextView: NSTextView { private final class EditorTextView: NSTextView {
var onFocusStateChange: ((NSTextView) -> Void)? var onFocusStateChange: ((NSTextView) -> Void)?
var onUserEditingInteraction: ((NSTextView) -> Void)?
override var acceptsFirstResponder: Bool { override var acceptsFirstResponder: Bool {
true true
@ -551,6 +566,21 @@ private final class EditorTextView: NSTextView {
} }
return resignedFirstResponder return resignedFirstResponder
} }
override func mouseDown(with event: NSEvent) {
onUserEditingInteraction?(self)
super.mouseDown(with: event)
}
override func keyDown(with event: NSEvent) {
onUserEditingInteraction?(self)
super.keyDown(with: event)
}
override func paste(_ sender: Any?) {
onUserEditingInteraction?(self)
super.paste(sender)
}
} }
private final class ChecklistOverlayButton: NSButton { private final class ChecklistOverlayButton: NSButton {
@ -602,6 +632,221 @@ private final class ComfortableEditorScrollView: NSScrollView {
} }
} }
} }
#if DEBUG
@MainActor
public final class HybridMarkdownLiveEditorHarness {
public private(set) var text: String
public private(set) var selection: EditorSelection
public private(set) var renderPasses: [EditorRenderPassMetric] = []
private let box: StateBox
private let coordinator: NativeMarkdownTextView.Coordinator
private let window: NSWindow
private let scrollView: ComfortableEditorScrollView
private let textView: EditorTextView
public init(source: String, selectedRange: NSRange = NSRange(location: 0, length: 0)) {
let stateBox = StateBox(text: source, selection: EditorSelection(range: selectedRange))
self.text = source
self.selection = EditorSelection(range: selectedRange)
self.box = stateBox
let lineIndex = DocumentLineIndex(source: source)
let parent = NativeMarkdownTextView(
text: Binding(
get: { stateBox.text },
set: { stateBox.text = $0 }
),
selection: Binding(
get: { stateBox.selection },
set: { stateBox.selection = $0 }
),
activeLineIndex: lineIndex.lineIndex(containing: selectedRange.location),
lineIndex: lineIndex,
onTextEdit: { updatedText, edit, updatedSelection in
stateBox.text = updatedText
if let edit {
stateBox.lineIndex.replace(edit, updatedSource: updatedText)
} else {
stateBox.lineIndex = DocumentLineIndex(source: updatedText)
}
if let updatedSelection {
stateBox.selection = updatedSelection
}
},
onRenderPass: { metric in
stateBox.renderPasses.append(metric)
}
)
self.coordinator = parent.makeCoordinator()
self.coordinator.currentLineIndex = lineIndex
self.scrollView = ComfortableEditorScrollView()
self.scrollView.hasVerticalScroller = true
self.scrollView.hasHorizontalScroller = false
self.scrollView.autohidesScrollers = true
self.scrollView.borderType = .noBorder
self.scrollView.drawsBackground = true
self.scrollView.backgroundColor = .textBackgroundColor
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
self.textView = EditorTextView(frame: NSRect(x: 0, y: 0, width: 640, height: 480), textContainer: textContainer)
self.textView.autoresizingMask = [.width]
self.textView.minSize = NSSize(width: 0, height: 480)
self.textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
self.textView.isVerticallyResizable = true
self.textView.isHorizontallyResizable = false
self.textView.onFocusStateChange = { [weak coordinator] textView in
coordinator?.applyHybridAttributes(to: textView)
}
self.textView.onUserEditingInteraction = { [weak coordinator] textView in
coordinator?.activateEditingPresentation(in: textView)
}
self.textView.string = source
self.textView.delegate = coordinator
self.textView.setSelectedRange(selectedRange)
self.textView.isRichText = false
self.textView.isEditable = true
self.textView.isSelectable = true
self.textView.isAutomaticQuoteSubstitutionEnabled = false
self.textView.isAutomaticDashSubstitutionEnabled = false
self.textView.isAutomaticTextReplacementEnabled = false
self.textView.allowsUndo = true
self.textView.usesFindPanel = true
self.textView.isContinuousSpellCheckingEnabled = true
self.textView.backgroundColor = .textBackgroundColor
self.textView.insertionPointColor = .controlAccentColor
self.textView.font = .systemFont(ofSize: 16, weight: .regular)
self.textView.textContainer?.widthTracksTextView = true
self.textView.textContainer?.containerSize = NSSize(width: 640, height: CGFloat.greatestFiniteMagnitude)
self.scrollView.frame = NSRect(x: 0, y: 0, width: 640, height: 480)
self.scrollView.documentView = textView
self.scrollView.editorTextView = textView
self.scrollView.updateEditorInsets()
self.window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
self.window.contentView = scrollView
self.coordinator.applyHybridAttributes(to: textView)
syncState()
}
public func simulateLaunchFirstResponder() {
window.makeFirstResponder(textView)
syncState()
}
public func simulateFocusAway() {
window.makeFirstResponder(nil)
syncState()
}
public func setSelection(_ range: NSRange) {
coordinator.activateEditingPresentation(in: textView)
coordinator.setSelection(range, in: textView)
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
syncState()
}
public func clickRenderedCheckbox(lineIndex: Int) {
guard let button = checklistButton(lineIndex: lineIndex) else { return }
button.performClick(nil)
syncState()
}
public func headingMarkerIsHidden() -> Bool {
isHidden(at: 0)
}
public func point(for text: String) -> CGPoint? {
let textRange = (textView.string as NSString).range(of: text)
guard textRange.location != NSNotFound,
let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer
else { return nil }
layoutManager.ensureLayout(for: textContainer)
let glyphRange = layoutManager.glyphRange(
forCharacterRange: NSRange(location: textRange.location, length: 1),
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 = textView.textContainerOrigin
return CGPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
}
public func presentationSignature() -> String {
guard let storage = textView.textStorage else { return "" }
return MarkdownPresentationSnapshot.make(
from: storage,
lineIndex: coordinator.currentLineIndex,
containerWidth: textView.textContainer?.containerSize.width ?? 640
).signature
}
public func checklistButtonFrame(lineIndex: Int) -> CGRect? {
checklistButton(lineIndex: lineIndex)?.frame
}
public func selectedRange() -> NSRange {
textView.selectedRange()
}
public func source() -> String {
textView.string
}
public func effectiveActiveLineIndex() -> Int {
coordinator.currentLineIndex.lineIndex(containing: textView.selectedRange().location)
}
private func checklistButton(lineIndex: Int) -> ChecklistOverlayButton? {
textView.subviews.compactMap { $0 as? ChecklistOverlayButton }.first { $0.task?.lineIndex == lineIndex }
}
private func isHidden(at location: Int) -> Bool {
guard let textStorage = textView.textStorage else { return false }
let color = textStorage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor
let font = textStorage.attribute(.font, at: location, effectiveRange: nil) as? NSFont
return color?.alphaComponent == 0 && (font?.pointSize ?? 0) < 1
}
private func syncState() {
text = box.text
selection = box.selection
renderPasses = box.renderPasses
}
private final class StateBox {
var text: String
var selection: EditorSelection
var lineIndex: DocumentLineIndex
var renderPasses: [EditorRenderPassMetric] = []
init(text: String, selection: EditorSelection) {
self.text = text
self.selection = selection
self.lineIndex = DocumentLineIndex(source: text)
}
}
}
#endif
#elseif os(iOS) #elseif os(iOS)
private struct NativeMarkdownTextView: UIViewRepresentable { private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String @Binding var text: String
@ -617,8 +862,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let textView = UITextView() let textView = UITextView()
textView.delegate = context.coordinator
textView.text = text textView.text = text
textView.delegate = context.coordinator
textView.allowsEditingTextAttributes = false textView.allowsEditingTextAttributes = false
textView.autocorrectionType = .yes textView.autocorrectionType = .yes
textView.smartDashesType = .no textView.smartDashesType = .no

View file

@ -0,0 +1,66 @@
import XCTest
@testable import SaplingEditor
#if os(macOS)
import AppKit
@MainActor
final class HybridMarkdownLiveEditorHarnessTests: XCTestCase {
func testLiveInitialFirstResponderDoesNotShowHeadingSourceBeforeUserInteraction() {
let harness = HybridMarkdownLiveEditorHarness(source: "# Heading\nParagraph")
harness.simulateLaunchFirstResponder()
XCTAssertTrue(harness.headingMarkerIsHidden())
}
func testLiveParagraphGeometryReturnsAfterClickAndFocusAway() throws {
let source = """
# Heading
Paragraph with **bold**, *italic*, `code`, and [Link](https://example.com) markers.
Outro
"""
let harness = HybridMarkdownLiveEditorHarness(source: source)
harness.simulateLaunchFirstResponder()
let initialPoint = try XCTUnwrap(harness.point(for: "Paragraph"))
let paragraphLocation = (source as NSString).range(of: "bold").location
harness.setSelection(NSRange(location: paragraphLocation, length: 0))
harness.simulateFocusAway()
let finalPoint = try XCTUnwrap(harness.point(for: "Paragraph"))
XCTAssertEqual(initialPoint.x, finalPoint.x, accuracy: 0.001)
XCTAssertEqual(initialPoint.y, finalPoint.y, accuracy: 0.001)
}
func testLiveCheckboxClickPreservesSelectionActiveLineAndGeometry() throws {
let source = "Editing here\n* [ ] Move with arrow keys."
let editingRange = (source as NSString).range(of: "Editing")
let harness = HybridMarkdownLiveEditorHarness(
source: source,
selectedRange: NSRange(location: editingRange.location, length: 0)
)
harness.simulateLaunchFirstResponder()
harness.setSelection(NSRange(location: editingRange.location, length: 0))
let selectionBefore = harness.selectedRange()
let activeLineBefore = harness.effectiveActiveLineIndex()
let labelPointBefore = try XCTUnwrap(harness.point(for: "Move"))
let checkboxFrameBefore = try XCTUnwrap(harness.checklistButtonFrame(lineIndex: 1))
harness.clickRenderedCheckbox(lineIndex: 1)
let labelPointAfter = try XCTUnwrap(harness.point(for: "Move"))
let checkboxFrameAfter = try XCTUnwrap(harness.checklistButtonFrame(lineIndex: 1))
XCTAssertEqual(harness.selectedRange(), selectionBefore)
XCTAssertEqual(harness.effectiveActiveLineIndex(), activeLineBefore)
XCTAssertTrue(harness.source().contains("* [x] Move with arrow keys."))
XCTAssertEqual(labelPointBefore.x, labelPointAfter.x, accuracy: 0.001)
XCTAssertEqual(labelPointBefore.y, labelPointAfter.y, accuracy: 0.001)
XCTAssertEqual(checkboxFrameBefore.origin.x, checkboxFrameAfter.origin.x, accuracy: 0.001)
XCTAssertEqual(checkboxFrameBefore.origin.y, checkboxFrameAfter.origin.y, accuracy: 0.001)
XCTAssertEqual(checkboxFrameBefore.size.width, checkboxFrameAfter.size.width, accuracy: 0.001)
XCTAssertEqual(checkboxFrameBefore.size.height, checkboxFrameAfter.size.height, accuracy: 0.001)
}
}
#endif