InlineTokenField

main

A macOS SwiftUI text field for mixing free text with inline token pills.
alibosworth/InlineTokenField

InlineTokenField

A macOS SwiftUI text field that supports inline token pills mixed with free text.

Unlike NSTokenField, which tokenizes all text, InlineTokenField lets users type free text and insert specific named tokens as styled pills at the cursor — useful for path templates, rename patterns, and similar structured strings.

InlineTokenField-demo.mov

Requirements

  • macOS 14.0+
  • Swift 5.9+, Swift 6 compatible
  • AppKit-backed — macOS only, no iOS support

Editor note: SourceKit may show "No such module 'PackageDescription'" and cross-file type errors. These are false positives — the package builds correctly with swift build.

Installation

// Package.swift
.package(url: "https://github.com/alibosworth/InlineTokenField", from: "0.1.0")

Usage

Core API

import SwiftUI
import InlineTokenField

struct MyView: View {
    @State private var segments: [TokenSegment] = [
        .text("../"),
        .token("INPUT_DIR"),
        .text("_processed")
    ]
    @StateObject private var controller = InlineTokenFieldController()

    var body: some View {
        VStack {
            InlineTokenField(
                value: $segments,
                tokens: ["INPUT_DIR", "OUTPUT_DIR", "FILENAME"],
                controller: controller
            )

            // Trigger insertion from a SwiftUI button while the field is on screen
            Button("Insert INPUT_DIR") { controller.insertToken("INPUT_DIR") }
        }
    }
}

InlineTokenFieldController is a view helper — it holds a weak reference to the mounted field and only works while the field is in the view hierarchy.

tokens: controls what the controller can insert. It is not a validator on the bound value: any .token("X") already present in [TokenSegment] still renders as a pill even if "X" is not in the current tokens array.

Convenience row

InlineTokenFieldRow bundles the field with an insert button per token:

InlineTokenFieldRow(
    value: $segments,
    tokens: ["INPUT_DIR", "OUTPUT_DIR", "FILENAME"],
    tokenLabels: ["INPUT_DIR": "Input", "OUTPUT_DIR": "Output", "FILENAME": "Filename"],
    fieldHeight: 24  // optional, defaults to 24
)

Template string serialization

If you need to store or exchange the value as a plain string, TokenTemplate provides an opt-in [TOKEN] serialization format:

// [TokenSegment] → String
let str = TokenTemplate.string(from: segments)
// e.g. "../[INPUT_DIR]_processed"

// String → [TokenSegment]
let segments = TokenTemplate.parse("../[INPUT_DIR]_processed", tokens: ["INPUT_DIR", "OUTPUT_DIR"])

[X] is parsed as a token segment only when X exactly matches a string in the tokens array. Unrecognized brackets (e.g. [unknown], unclosed [) are passed through as plain text with brackets intact.

The segment round-trip is stable: a [TokenSegment] value serialized to a string and parsed back with the same token set produces identical segments. The inverse is not guaranteed — an arbitrary string parsed and re-serialized may differ if it contains unrecognized brackets.

Limitation: TokenTemplate does not support escape syntax in 0.1.0. If your plain text can legitimately contain [knownTokenName], work with [TokenSegment] directly rather than using the template string format.

Custom style

import AppKit

let style = TokenStyle(
    font: .systemFont(ofSize: 13, weight: .medium),
    horizontalPadding: 7,
    verticalPadding: 1,
    fillColor: .systemBlue.withAlphaComponent(0.15),
    strokeColor: .systemBlue.withAlphaComponent(0.4),
    textColor: .systemBlue
)
InlineTokenField(value: $segments, tokens: tokens, controller: controller, style: style)

Set showsCloseButton: false to hide the inline × button and rely on selection + backspace for deletion:

let style = TokenStyle(
    font: .systemFont(ofSize: 13, weight: .medium),
    horizontalPadding: 7,
    verticalPadding: 1,
    fillColor: .systemBlue.withAlphaComponent(0.15),
    strokeColor: .systemBlue.withAlphaComponent(0.4),
    textColor: .systemBlue,
    showsCloseButton: false
)

Behavior Guarantees

  • Token copy/paste is preserved within the field — cut, copy, and paste of a selection containing token pills restores the pills intact. Token values are preserved; pasted pills are rendered using the field's current style at paste time. Tokens are written to the pasteboard using a structured payload alongside the system RTF representation.
  • Token body and close affordance are independent interaction regions — clicking a token body selects it as an inline object; clicking the × deletes it. Hover, cursor, and hit-testing for the × share one geometry; they do not drift independently.
  • Plain text cursor semantics apply only to plain text — the I-beam cursor and normal text-editing behavior are not active over token bodies or the close affordance.
  • Undefined: pasting token content from outside the field — the custom pasteboard type is only written by this field. Pasting token-shaped RTF from an external source may or may not reconstruct pills depending on whether the attachment data survives the roundtrip.

Constraints

  • macOS only — AppKit-backed, no UIKit port
  • Single-line — horizontally scrolling, vertical wrapping disabled
  • tokens: is the insertable set, not a validator — the field renders any .token(string) as a pill regardless of whether that string is currently in tokens. A saved document can contain tokens that are no longer in the active set.
  • TokenTemplate bracket semantics are contract surface — the [TOKEN] format is stable as of 0.1.0. Escape syntax is not supported; see limitation above.
  • Token interactions are custom AppKit behavior — token bodies behave as selectable inline objects, the × close affordance has its own hover/click target, and only plain text uses normal text-editing cursor semantics.

Running the demo

swift run InlineTokenFieldDemo

The demo shows two fields — one with the × close button, one without — along with token insert buttons and a live segment/template debug display.

Why not NSTokenField?

NSTokenField tokenizes every word the user types — it's designed for tag/recipient inputs where all content is tokens. InlineTokenField is for content where most text is free and only specific named values need to be visually distinguished.

fcanas/TokenField (SPM) wraps NSTokenField and has the same limitation. FriedText took a similar NSTextView+NSTextAttachment approach and was consulted as a reference, but is archived.

How it works

Tokens are stored as NSTextAttachment with a NSTextAttachmentCell subclass that draws the pill. The token value lives in the attachment's FileWrapper data. The binding is [TokenSegment] — a structured value with no in-band sentinel logic.

Within the field, cut/copy/paste of token content is preserved by writing a custom segment payload to the pasteboard and reconstructing token attachments on paste. Hover, cursor, and click behavior for the × close affordance are driven by shared custom hit geometry rather than NSTextView defaults.

Swift 6 concurrency note: NSTextAttachmentCell inherits @MainActor from NSCell, but AppKit calls drawing overrides from a nonisolated context. All state accessed from drawing overrides lives at file scope with nonisolated(unsafe) — never as stored properties on the cell.

Implementation Notes

  • NSTextView's default cursor handling conflicts with token-specific hover regions, so cursor choice is centralized in TokenNSTextView.
  • Token bodies behave as selectable inline objects; the × close affordance is a separate interactive region; only plain text should use normal text-editing cursor semantics.
  • Hover color, cursor choice, and click hit-testing for the × close affordance must use the same hit geometry. If these drift apart, cursor flicker and inconsistent hover behavior return quickly.

License

MIT © 2026

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sun May 17 2026 16:55:28 GMT-0900 (Hawaii-Aleutian Daylight Time)