Snippet

Wrap NSTextView for a plain-text SwiftUI editor

A complete SwiftUI wrapper around AppKit NSTextView with plain-text enforcement, word wrap, selection callbacks, and first-responder focus.

swiftOct 6, 2026MIT

AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy

What it does#

SwiftUI’s TextEditor is handy until you need editor-grade control. This snippet wraps AppKit NSTextView in NSViewRepresentable, keeps the editor plain text, reports selection changes, supports word wrap, and asks the window to focus the text view when a tab becomes active.

You need#

  • macOS 15 or later
  • Xcode 27 or Swift 6.4
  • A SwiftUI macOS app target
  • No package dependencies

Where it goes#

Save it as PlainTextEditorView.swift in your macOS app target. Bind text and selection from your tab or document model.

PlainTextEditorView.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Sarang Datye
#if os(macOS)
import AppKit
import SwiftUI

struct TextSelection: Codable, Equatable, Sendable {
    var location: Int
    var length: Int

    init(location: Int = 0, length: Int = 0) {
        self.location = max(0, location)
        self.length = max(0, length)
    }
}

struct PlainTextEditorView: NSViewRepresentable {
    let text: String
    let selection: TextSelection
    let wordWrapEnabled: Bool
    let shouldBecomeFirstResponder: Bool
    let onTextChange: (String, TextSelection) -> Void
    var onSelectionChange: (TextSelection) -> Void = { _ in }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = !wordWrapEnabled
        scrollView.autohidesScrollers = true

        let textView = NSTextView()
        textView.isRichText = false
        textView.importsGraphics = false
        textView.allowsUndo = true
        textView.isAutomaticQuoteSubstitutionEnabled = false
        textView.isAutomaticDashSubstitutionEnabled = false
        textView.isAutomaticTextReplacementEnabled = false
        textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
        textView.string = text
        textView.delegate = context.coordinator
        textView.textContainerInset = NSSize(width: 8, height: 8)

        configure(textView: textView, scrollView: scrollView)
        scrollView.documentView = textView
        requestFocusIfNeeded(textView)
        return scrollView
    }

    func updateNSView(_ scrollView: NSScrollView, context: Context) {
        guard let textView = scrollView.documentView as? NSTextView else { return }
        context.coordinator.parent = self

        if textView.string != text {
            textView.string = text
        }

        let textLength = (textView.string as NSString).length
        let desiredRange = Self.clampedRange(selection: selection, textLength: textLength)
        if textView.selectedRange() != desiredRange {
            textView.setSelectedRange(desiredRange)
            textView.scrollRangeToVisible(desiredRange)
        }

        configure(textView: textView, scrollView: scrollView)
        requestFocusIfNeeded(textView)
    }

    static func clampedRange(selection: TextSelection, textLength: Int) -> NSRange {
        let boundedTextLength = max(0, textLength)
        let location = min(max(0, selection.location), boundedTextLength)
        let length = min(max(0, selection.length), max(0, boundedTextLength - location))
        return NSRange(location: location, length: length)
    }

    private func requestFocusIfNeeded(_ textView: NSTextView) {
        guard shouldBecomeFirstResponder else { return }
        DispatchQueue.main.async {
            guard textView.window?.firstResponder !== textView else { return }
            NSApplication.shared.setActivationPolicy(.regular)
            NSApplication.shared.activate(ignoringOtherApps: true)
            textView.window?.makeKeyAndOrderFront(nil)
            textView.window?.makeFirstResponder(textView)
        }
    }

    private func configure(textView: NSTextView, scrollView: NSScrollView) {
        scrollView.hasHorizontalScroller = !wordWrapEnabled
        if wordWrapEnabled {
            textView.isHorizontallyResizable = false
            textView.textContainer?.widthTracksTextView = true
            textView.textContainer?.containerSize = NSSize(
                width: scrollView.contentSize.width,
                height: CGFloat.greatestFiniteMagnitude
            )
            textView.autoresizingMask = [.width]
        } else {
            textView.isHorizontallyResizable = true
            textView.textContainer?.widthTracksTextView = false
            textView.textContainer?.containerSize = NSSize(
                width: CGFloat.greatestFiniteMagnitude,
                height: CGFloat.greatestFiniteMagnitude
            )
            textView.autoresizingMask = []
        }
    }

    final class Coordinator: NSObject, NSTextViewDelegate {
        var parent: PlainTextEditorView

        init(parent: PlainTextEditorView) {
            self.parent = parent
        }

        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            let range = textView.selectedRange()
            parent.onTextChange(
                textView.string,
                TextSelection(location: range.location, length: range.length)
            )
        }

        func textViewDidChangeSelection(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            let range = textView.selectedRange()
            parent.onSelectionChange(TextSelection(location: range.location, length: range.length))
        }

        func textView(
            _ textView: NSTextView,
            writablePasteboardTypesFor cell: NSTextAttachmentCellProtocol,
            at charIndex: Int
        ) -> [NSPasteboard.PasteboardType] {
            [.string]
        }
    }
}
#endif

Check that it works#

Run swiftc -typecheck PlainTextEditorView.swift on macOS. In the app, type text, switch word wrap on and off, and confirm the callback receives both the text and selection.