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.
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.
// 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.