An open API service indexing awesome lists of open source software.

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.

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