Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Demo/MarkdownEngineDemo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [
Expand All @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool>?
var fontName: String
var fontSize: CGFloat
var configuration: MarkdownEditorConfiguration = .default {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool>?
/// 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.
Expand Down Expand Up @@ -150,6 +154,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
fontSize: CGFloat = 16,
documentId: String = "default",
isEditable: Bool = true,
isFocused: Binding<Bool>? = nil,
onPasteImage: ((NSPasteboard) -> String?)? = nil,
onLinkClick: ((String) -> Void)? = nil,
onCaretRectChange: ((CGRect) -> Void)? = nil,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
135 changes: 135 additions & 0 deletions Tests/MarkdownEngineTests/FocusBindingTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}