fix(ui): verify live editor presentation path
This commit is contained in:
parent
f9530a9164
commit
75da8d10e1
2 changed files with 314 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue