diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index a51f5d3..807a45c 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -203,11 +203,14 @@ private struct NativeMarkdownTextView: NSViewRepresentable { textView.isVerticallyResizable = true textView.isHorizontallyResizable = false + textView.string = text textView.delegate = context.coordinator textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in coordinator?.applyHybridAttributes(to: textView) } - textView.string = text + textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView in + coordinator?.activateEditingPresentation(in: textView) + } textView.isRichText = false textView.isEditable = true textView.isSelectable = true @@ -261,6 +264,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? private var pendingEdit: DocumentLineIndexEdit? + private var hasUserActivatedEditing = false private var checklistButtons: [Int: ChecklistOverlayButton] = [:] init(_ parent: NativeMarkdownTextView) { @@ -284,6 +288,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { guard !isPerformingProgrammaticUpdate else { return } guard let textView = notification.object as? NSTextView else { return } + hasUserActivatedEditing = true let selection = EditorSelection(range: textView.selectedRange()) let edit = pendingEdit if let edit { @@ -358,10 +363,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } 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) } + func activateEditingPresentation(in textView: NSTextView) { + guard !hasUserActivatedEditing else { return } + hasUserActivatedEditing = true + applyHybridAttributes(to: textView) + } + func setSelection(_ range: NSRange, in textView: NSTextView) { guard textView.selectedRange() != range else { return } performProgrammaticUpdate { @@ -381,6 +394,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { func invalidateStylingCache() { lastStyledText = nil lastStyledActiveLineIndex = nil + hasUserActivatedEditing = false removeChecklistControls() } @@ -531,6 +545,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { private final class EditorTextView: NSTextView { var onFocusStateChange: ((NSTextView) -> Void)? + var onUserEditingInteraction: ((NSTextView) -> Void)? override var acceptsFirstResponder: Bool { true @@ -551,6 +566,21 @@ private final class EditorTextView: NSTextView { } 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 { @@ -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) private struct NativeMarkdownTextView: UIViewRepresentable { @Binding var text: String @@ -617,8 +862,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable { func makeUIView(context: Context) -> UITextView { let textView = UITextView() - textView.delegate = context.coordinator textView.text = text + textView.delegate = context.coordinator textView.allowsEditingTextAttributes = false textView.autocorrectionType = .yes textView.smartDashesType = .no diff --git a/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift new file mode 100644 index 0000000..ddbf719 --- /dev/null +++ b/Tests/SaplingEditorTests/HybridMarkdownLiveEditorHarnessTests.swift @@ -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