https://github.com/alibosworth/inlinetokenfield
A macOS SwiftUI text field for mixing free text with inline token pills.
https://github.com/alibosworth/inlinetokenfield
appkit macos spm swift swiftui
Last synced: 2 months ago
JSON representation
A macOS SwiftUI text field for mixing free text with inline token pills.
- Host: GitHub
- URL: https://github.com/alibosworth/inlinetokenfield
- Owner: alibosworth
- License: mit
- Created: 2026-04-22T16:34:44.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-22T16:48:04.000Z (2 months ago)
- Last Synced: 2026-04-29T16:07:00.720Z (2 months ago)
- Topics: appkit, macos, spm, swift, swiftui
- Language: Swift
- Homepage:
- Size: 19.5 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# 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.
## 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
```swift
// Package.swift
.package(url: "https://github.com/alibosworth/InlineTokenField", from: "0.1.0")
```
## Usage
### Core API
```swift
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:
```swift
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:
```swift
// [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
```swift
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:
```swift
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
```bash
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`](https://github.com/fcanas/TokenField) (SPM) wraps `NSTokenField` and has the same limitation. [FriedText](https://github.com/BenedictSt/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