diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fbc83f..c3986f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `NativeTextViewWrapper.isFocused` provides optional two-way first-responder + coordination for embedders without changing AppKit-owned focus by default. - **Directive seam (parsing)**: opt-in named inline commands with typed arguments, for constructs that need a name and parameters rather than delimiters. A `MarkdownDirective` declares a name, a form — self-contained diff --git a/Demo/MarkdownEngineDemo/ContentView.swift b/Demo/MarkdownEngineDemo/ContentView.swift index eeaa34c9..8d18a190 100644 --- a/Demo/MarkdownEngineDemo/ContentView.swift +++ b/Demo/MarkdownEngineDemo/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { @State private var isReadOnly = false @State private var showRawSource = false @State private var useReadingColumn = false + @State private var editorIsFocused = false /// Registers/unregisters BOTH opt-in seams at once. The document is written /// so that flipping this off is the whole explanation of what is core @@ -46,6 +47,7 @@ struct ContentView: View { configuration: configuration, fontSize: fontSize, isEditable: !isReadOnly, + isFocused: $editorIsFocused, placeholder: NSAttributedString( string: "Empty document — start typing, markdown styles live…", attributes: [ @@ -63,6 +65,12 @@ struct ContentView: View { .id(useReadingColumn) .toolbar { ToolbarItemGroup { + Button { + editorIsFocused = true + } label: { + Label("Focus editor", systemImage: "text.cursor") + } + Toggle(isOn: $isReadOnly) { Label("Read-only", systemImage: isReadOnly ? "lock" : "lock.open") } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index aa30264b..a1d6b93b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -46,6 +46,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var undoContentSnapshots: [String: String] = [:] @Binding var text: String @Binding var isWikiLinkActive: Bool + var isFocused: Binding? var fontName: String var fontSize: CGFloat var configuration: MarkdownEditorConfiguration = .default { @@ -175,6 +176,14 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { /// nil = no span, use the theme. var resolvedCaretColor: NSColor? + /// Mirrors an actual AppKit first-responder transition into the optional + /// host binding. Equality guards keep host-driven reconciliation from + /// feeding the same value back into SwiftUI. + func reportFocusChange(_ focused: Bool) { + guard let isFocused, isFocused.wrappedValue != focused else { return } + isFocused.wrappedValue = focused + } + var cachedCodeBlockTokens: [(index: Int, token: MarkdownToken)] = [] /// Dedupe key of the last emitted code-block selections — identical /// (parse version, scroll, width, active-code set) means identical output, diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift index c263d415..817f516d 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift @@ -46,6 +46,11 @@ final class NativeTextView: NSTextView { // MARK: Editor wiring var onPasteImage: ((NSPasteboard) -> String?)? + var onFocusChange: ((Bool) -> Void)? + private var reportedFocus = false + /// `nil` preserves AppKit-owned focus. A value represents the latest + /// explicit host request and stays pending until the view has a window. + var requestedFocus: Bool? weak var layoutBridge: LayoutBridge? var baseFont: NSFont = NSFont.systemFont(ofSize: NSFont.systemFontSize) @@ -74,6 +79,40 @@ final class NativeTextView: NSTextView { /// Persisted horizontal scroll offset per wide table; survives restyles. var tableHorizontalScrollOffsets: [Int: CGFloat] = [:] + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + reconcileRequestedFocus() + } + + override func becomeFirstResponder() -> Bool { + let didBecome = super.becomeFirstResponder() + if didBecome && !reportedFocus { + reportedFocus = true + onFocusChange?(true) + } + return didBecome + } + + override func resignFirstResponder() -> Bool { + let didResign = super.resignFirstResponder() + if didResign && reportedFocus { + reportedFocus = false + onFocusChange?(false) + } + return didResign + } + + func reconcileRequestedFocus() { + guard let requestedFocus, let window else { return } + if requestedFocus { + if window.firstResponder !== self { + window.makeFirstResponder(self) + } + } else if window.firstResponder === self { + window.makeFirstResponder(nil) + } + } + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() // Forward appearance changes to the embedder's highlighter via its registered notification. diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index e081003e..f2627b2b 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -71,6 +71,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable { public var documentId: String /// When `false` the editor renders read-only with no caret. public var isEditable: Bool + /// Optional two-way focus state. Set the binding to `true` to request first + /// responder status; user-driven focus and blur are written back. When no + /// binding is supplied, focus behavior remains entirely AppKit-managed. + public var isFocused: Binding? /// Optional paste hook. Return a Markdown image-embed string (e.g. /// `"![[my-image]]"`) to insert at the caret, or `nil` to fall through /// to the system's default plain-text paste. @@ -150,6 +154,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { fontSize: CGFloat = 16, documentId: String = "default", isEditable: Bool = true, + isFocused: Binding? = nil, onPasteImage: ((NSPasteboard) -> String?)? = nil, onLinkClick: ((String) -> Void)? = nil, onCaretRectChange: ((CGRect) -> Void)? = nil, @@ -176,6 +181,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { self.fontSize = fontSize self.documentId = documentId self.isEditable = isEditable + self.isFocused = isFocused self.onPasteImage = onPasteImage self.onLinkClick = onLinkClick self.onCaretRectChange = onCaretRectChange @@ -334,6 +340,11 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.onInlineSelectionChange = onInlineSelectionChange context.coordinator.onInlinePreviewKey = onInlinePreviewKey context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange + context.coordinator.isFocused = isFocused + textView.onFocusChange = { [weak coordinator = context.coordinator] focused in + coordinator?.reportFocusChange(focused) + } + textView.requestedFocus = isFocused?.wrappedValue textView.recalcOverscroll(for: scrollView) textView.setPlaceholder(placeholder) @@ -406,6 +417,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable { // to reach the CURRENT closures even when the pass below returns early. context.coordinator.onPersistScrollOffset = onPersistScrollOffset context.coordinator.restoreScrollOffset = restoreScrollOffset + context.coordinator.isFocused = isFocused + textView.requestedFocus = isFocused?.wrappedValue + textView.reconcileRequestedFocus() // Drop remembered offsets for documents no longer retained (always keep // the current one). Only rebuilds the dict when something must go. diff --git a/Tests/MarkdownEngineTests/FocusBindingTests.swift b/Tests/MarkdownEngineTests/FocusBindingTests.swift new file mode 100644 index 00000000..a6da9460 --- /dev/null +++ b/Tests/MarkdownEngineTests/FocusBindingTests.swift @@ -0,0 +1,135 @@ +// +// FocusBindingTests.swift +// MarkdownEngineTests +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Embedder focus binding", .serialized) +struct FocusBindingTests { + @Test("A pending focus request is fulfilled after window attachment") + func pendingRequest() { + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + textView.requestedFocus = true + + let window = makeWindow(containing: textView) + + #expect(window.firstResponder === textView) + } + + @Test("An attached editor fulfills a true request during reconciliation") + func attachedRequest() throws { + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let other = NSTextField(frame: NSRect(x: 0, y: 110, width: 200, height: 24)) + let window = makeWindow(containing: textView, other) + try #require(window.makeFirstResponder(other)) + + textView.requestedFocus = true + textView.reconcileRequestedFocus() + + #expect(window.firstResponder === textView) + } + + @Test("False releases this editor when it owns focus") + func falseReleasesEditor() throws { + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let window = makeWindow(containing: textView) + try #require(window.makeFirstResponder(textView)) + + textView.requestedFocus = false + textView.reconcileRequestedFocus() + + #expect(window.firstResponder !== textView) + } + + @Test("False only resigns this editor") + func falseDoesNotDisturbAnotherResponder() throws { + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let other = NSTextField(frame: NSRect(x: 0, y: 110, width: 200, height: 24)) + let window = makeWindow(containing: textView, other) + try #require(window.makeFirstResponder(other)) + let otherResponder = try #require(window.firstResponder) + + textView.requestedFocus = false + textView.reconcileRequestedFocus() + + #expect(window.firstResponder === otherResponder) + #expect(window.firstResponder !== textView) + } + + @Test("Omitting focus state leaves AppKit ownership unchanged") + func omittedBindingCompatibility() throws { + let wrapper = NativeTextViewWrapper(text: .constant("")) + #expect(wrapper.isFocused == nil) + + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let other = NSTextField(frame: NSRect(x: 0, y: 110, width: 200, height: 24)) + let window = makeWindow(containing: textView, other) + try #require(window.makeFirstResponder(other)) + let otherResponder = try #require(window.firstResponder) + + textView.requestedFocus = nil + textView.reconcileRequestedFocus() + + #expect(window.firstResponder === otherResponder) + #expect(window.firstResponder !== textView) + } + + @Test("First-responder changes are reported once per transition") + func reportsTransitions() throws { + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let other = NSTextField(frame: NSRect(x: 0, y: 110, width: 200, height: 24)) + let window = makeWindow(containing: textView, other) + try #require(window.makeFirstResponder(other)) + var changes: [Bool] = [] + textView.onFocusChange = { changes.append($0) } + + try #require(window.makeFirstResponder(textView)) + try #require(window.makeFirstResponder(other)) + + #expect(changes == [true, false]) + } + + @Test("The coordinator mirrors focus without echoing equal values") + func coordinatorBinding() { + var focused = false + var writes = 0 + let binding = Binding( + get: { focused }, + set: { focused = $0; writes += 1 } + ) + let coordinator = NativeTextViewCoordinator( + text: .constant(""), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.isFocused = binding + + coordinator.reportFocusChange(true) + coordinator.reportFocusChange(true) + coordinator.reportFocusChange(false) + + #expect(focused == false) + #expect(writes == 2) + } + + private func makeWindow(containing views: NSView...) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: window.contentView?.bounds ?? .zero) + views.forEach { content.addSubview($0) } + window.contentView = content + return window + } +}