To send a SwiftUI menu command to the active NSTextView, resolve the key window’s first responder when the command runs, then send the action to that view. This tutorial builds a two-editor example whose Select First Line command affects only the focused editor.

The task is a custom menu action inside your own macOS app. The example uses synthetic text, an app-owned NSTextView subclass, and public SwiftUI and AppKit APIs. Its key design decision is whether a command should follow AppKit’s responder chain or require one specific active editor.

Choose what “active text view” means

For this example, the active editor is the SampleTextView instance that is currently the key window’s first responder. Apple defines the key window as the window receiving keyboard events; its firstResponder identifies the responder that ordinarily receives events and actions first. Reading these properties at invocation time gives the command its current destination. Apple’s keyWindow documentation, Apple’s firstResponder documentation.

AppKit also supports untargeted actions: pass nil as the target to NSApplication.sendAction(_:to:from:), and the application searches for a responder implementing the selector. That search starts at the key window’s first responder, but it can continue through delegates and the main window before reaching application-level handlers. Consequently, an untargeted action does not mean “execute only on this key window’s active text view.” Apple’s action-routing documentation.

Required behavior Routing choice Consequence
Let AppKit find an eligible handler Send the action with to: nil Normal responder-chain fallback applies.
Affect only the active app-owned editor Resolve and type-check the first responder, then target it explicitly The command stops if that editor is not active.
Operate on a particular editor regardless of focus Supply that editor as the target This is a different interaction from an active-editor command.

The implementation below chooses the second behavior. Checking the custom subclass also keeps an unrelated text field’s editor from becoming the destination merely because it is a text view.

Build the complete SwiftUI and AppKit example

Create a macOS app project with SwiftUI and use a deployment target of macOS 13 or later for this example. Replace the generated app entry point with the following single Swift file; remove any other @main declaration. The program supplies both editors, their initial text, the menu, and the action handler.

SwiftUI’s NSViewRepresentable is the supported bridge for embedding an AppKit view. Scene commands provide the menu contents, and CommandMenu creates an app-specific menu in the macOS menu bar. Apple’s NSViewRepresentable documentation, scene commands, command menus.

import SwiftUI
import AppKit
import Foundation

@main
@MainActor
struct RoutingDemoApp: App {
    var body: some Scene {
        WindowGroup("Routing Demo") {
            ContentView()
        }
        .commands {
            CommandMenu("Sample Actions") {
                Button("Select First Line") {
                    Self.routeSelectFirstLine()
                }
            }
        }
    }

    private static func routeSelectFirstLine() {
        let application = NSApplication.shared
        guard let editor = application.keyWindow?.firstResponder
                as? SampleTextView else {
            return
        }

        _ = application.sendAction(
            #selector(SampleTextView.selectFirstSampleLine(_:)),
            to: editor,
            from: nil
        )
    }
}

@MainActor
struct ContentView: View {
    @State private var windowLabel = "Sample Window"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Window label", text: $windowLabel)

            HStack(spacing: 12) {
                SampleEditor(initialText: "Sample Alpha\nAlpha details")
                SampleEditor(initialText: "Sample Beta\nBeta details")
            }

            Text("Click an editor, then use Sample Actions.")
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 640, minHeight: 320)
    }
}

@MainActor
final class SampleTextView: NSTextView {
    @objc func selectFirstSampleLine(_ sender: Any?) {
        guard isSelectable, !hasMarkedText() else { return }

        let contents = string as NSString
        guard contents.length > 0 else { return }

        let firstLine = contents.lineRange(
            for: NSRange(location: 0, length: 0)
        )
        setSelectedRange(firstLine)
        scrollRangeToVisible(firstLine)
    }
}

@MainActor
struct SampleEditor: NSViewRepresentable {
    let initialText: String

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.borderType = .bezelBorder

        let editor = SampleTextView(
            frame: NSRect(x: 0, y: 0, width: 300, height: 240)
        )
        editor.isEditable = true
        editor.isSelectable = true
        editor.isRichText = false
        editor.allowsUndo = true
        editor.font = NSFont.monospacedSystemFont(
            ofSize: 14, weight: .regular
        )
        editor.minSize = .zero
        editor.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.isVerticallyResizable = true
        editor.isHorizontallyResizable = false
        editor.autoresizingMask = [.width]
        editor.textContainer?.containerSize = NSSize(
            width: 300,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.textContainer?.widthTracksTextView = true
        editor.string = initialText

        scrollView.documentView = editor
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        // AppKit owns the editable contents in this example.
    }
}

This is a complete app example, with in-memory editor contents. It was inspected against the retrieved API documentation; it was not compiled or run here because the available environment is Linux and has no Swift toolchain.

Why the selector reaches the intended editor

The routing function obtains the first responder immediately before dispatch. It accepts only SampleTextView and supplies that exact object as the action target. The menu therefore does not retain whichever editor was previously clicked, and it does not search another window when the type check fails.

The action method uses @objc because target-action dispatch uses Objective-C selectors. The #selector expression references the declared method rather than a hand-written selector string. Apple documents this combination for invoking Swift methods through Objective-C target-action APIs. Using Objective-C Runtime Features in Swift.

The return value from sendAction reports whether the message was sent. It does not report whether the handler changed the selection: the handler can receive the message and return early for empty contents or marked text. This example intentionally discards the dispatch result because the destination and method are already known. Apple’s sendAction return-value documentation.

What the selection command actually selects

The handler asks NSString.lineRange(for:) for the logical line containing the start of the string. That range includes its line terminator, when present. It therefore selects the first newline-delimited line, even when that line wraps across several displayed rows. Apple’s line-range documentation.

For the supplied text, the left editor selects Sample Alpha and its following newline; the right editor selects Sample Beta and its following newline. The command replaces the previous selection. It does not change the text.

setSelectedRange(_:) applies the selection, and scrollRangeToVisible(_:) brings its first characters into view. The empty-content check avoids attempting a selection operation without any text. The marked-text check is an explicit example policy: defer this custom selection action while input composition is active. Selection ranges, scrolling a range into view, marked text.

Keep view creation, editing, and focus separate

The representable creates an NSScrollView and installs the custom text view as its document view. The editor grows vertically, while its text container tracks its width. This follows Apple’s documented scrolling-text-view arrangement. That arrangement appears in Apple’s archived AppKit guide; the SwiftUI bridge and action APIs cited here use current documentation. Putting an NSTextView Object in an NSScrollView.

SwiftUI controls the layout of the represented scroll view. The code configures the nested document view’s initial geometry and AppKit resizing behavior; it does not repeatedly assign the represented scroll view’s frame. Apple specifically cautions against changing the layout properties that SwiftUI manages for a representable. Apple’s representable layout guidance.

The sample assigns initialText only during creation. Its empty update method deliberately leaves subsequent typing under AppKit’s control. If your application needs a SwiftUI document model, add a coordinator and delegate-based synchronization, with a defined policy for model changes arriving during editing. Reassigning the initial string during every update would replace the user’s current contents. Apple describes coordinators as the bridge for forwarding AppKit interactions into SwiftUI. Apple’s representable coordination guidance.

Clicking an editor establishes focus for the walkthrough. If your app needs to move focus programmatically, use the containing window’s makeFirstResponder(_:) after the view belongs to that window. Apple explicitly says not to invoke the text view’s becomeFirstResponder() directly. Avoid requesting focus during every SwiftUI update, since doing so would repeatedly redirect the user’s editing destination. Apple’s text-view focus guidance.

Preserve your existing TextKit configuration

Menu routing does not require changing the text layout engine. This sample uses NSTextView(frame:); Apple documents that initializer as using NSLayoutManager by default. Explicit construction with NSTextLayoutManager is supported from macOS 12. Apple’s text-view initialization documentation.

For an existing TextKit 2 editor, retain its construction and add the action to its subclass. Avoid reading layoutManager merely to locate or route to the editor: Apple documents that accessing that property on a TextKit 2 text view can switch it into compatibility mode. The routing code only needs the responder and the action method. Apple’s NSTextView compatibility-mode documentation.

Verify routing and handle unavailable commands

Use this sequence to check the intended behavior on macOS. These are expected results derived from the implementation, not reported execution results.

  1. Click in the left editor, then choose Sample Actions → Select First Line. The left editor’s first logical line should become selected.
  2. Click in the right editor and invoke the same command. Only the right editor’s selection should change.
  3. Click in Window label and invoke the command. Both custom editors should remain unchanged because the first responder fails the subclass check.
  4. Delete all contents from an editor and invoke the command there. The empty-content guard should return without changing text.
  5. Add several lines, scroll down, and invoke the command. The first line should become selected and scroll into view.

The command remains enabled in this minimal example, including when focus is elsewhere. Its invocation-time guard determines whether it acts. Reading keyWindow?.firstResponder inside a SwiftUI .disabled expression alone would not establish a mechanism for updating that expression whenever AppKit focus changes.

For a larger SwiftUI interface, publish an appropriate command capability through focus-aware state, observe it with @FocusedValue, and derive the button’s .disabled condition from that state. The AppKit bridge must keep the capability aligned with actual editor focus. Retain the invocation-time check as well. Apple documents FocusedValue as observing values from the focused view or its ancestors. Focused values, disabling interaction.

If you instead build an AppKit menu item whose target is the editor, validateMenuItem(_:) provides AppKit’s validation hook. Adding that method to this subclass would not, by itself, validate the SwiftUI button: its action is a closure that subsequently dispatches another action. Apple requires the validation object to be the menu item’s target. Apple’s menu-item validation documentation.

Choose the target policy before adding more commands. Use an untargeted action when responder-chain fallback is part of the desired behavior; resolve and target the first responder when the operation belongs exclusively to the active editor. That choice lets the same menu remain predictable as users move between text views and other controls.

Sources