Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

NSTextInputClient on macOS: IME Composition in Custom Editors

Implement AppKit text input correctly by modeling marked text, replacement ranges, candidate geometry, command routing, and Unicode-safe composition.

Keyboard input in an AppKit editor is not a stream of characters that the view can append one key at a time. Input methods may compose several keystrokes into a temporary marked string, show a candidate window, revise that string, and only later commit the user’s chosen text. Chinese, Japanese, Korean, dead-key accents, dictation, and other system input paths rely on the text input contract. A custom editor that handles only the final ASCII-looking keystroke can appear to work in one locale while corrupting composition in another.

NSTextInputClient is the protocol for a custom text view that participates in the system text input management system. If ordinary text editing fits the product, subclass or configure NSTextView instead of implementing this protocol from scratch. The standard text view already owns many difficult details, including marked text, key bindings, selection, and accessibility interactions.

Composition is a temporary document state

Marked text is provisional text currently controlled by an input method. The client reports whether marked text exists and its range, replaces the marked range as new composition arrives, and removes the mark when the input system commits or cancels composition. Treat this state as part of the live editing model, but keep it distinguishable from committed content so undo, spell checking, syntax highlighting, and autosave do not accidentally publish incomplete composition as final input.

setMarkedText(_:selectedRange:replacementRange:) can replace either the existing marked range or another range supplied by the input system. The selected range is relative to the newly supplied marked string; the replacement range is expressed in the document’s text coordinate system. Do not assume they use the same origin. insertText(_:replacementRange:) is the commit path and may also replace a supplied range. Both paths can receive NSString or NSAttributedString values.

A useful internal model is an immutable committed string plus an active composition range, or a single text buffer with a clearly tracked marked range. In either design, update the selection and marked range atomically with the text replacement. A broken intermediate state can cause the candidate window to jump, lose its underline, or commit into the wrong place after a subsequent callback.

import Foundation

struct CompositionBuffer {
    private(set) var text: String
    private(set) var markedRange: NSRange = NSRange(location: NSNotFound, length: 0)
    private(set) var selectedRange: NSRange

    init(_ initialText: String) {
        text = initialText
        selectedRange = NSRange(location: (text as NSString).length, length: 0)
    }

    mutating func replace(_ range: NSRange, with value: String) -> NSRange {
        let currentLength = (text as NSString).length
        let safeRange = range.location == NSNotFound ? selectedRange : range
        guard safeRange.location != NSNotFound,
              safeRange.location >= 0,
              safeRange.length >= 0,
              safeRange.location <= currentLength,
              safeRange.length <= currentLength - safeRange.location else {
            return NSRange(location: NSNotFound, length: 0)
        }

        text = (text as NSString).replacingCharacters(in: safeRange, with: value)
        return NSRange(location: safeRange.location, length: (value as NSString).length)
    }
}

This small value model demonstrates range validation and UTF-16 lengths; it is not a complete NSTextInputClient. A production client must implement every required protocol method, return an attributed substring where requested, map screen geometry, answer command selectors, and synchronize model changes with drawing and accessibility. Validate ranges before calling Foundation replacement APIs; malformed ranges should never crash the process.

Unicode and range discipline

AppKit text-input ranges use NSRange, whose offsets correspond to UTF-16 units in the text storage. Swift String.count counts extended grapheme clusters, which is a different unit. Never use String.count to build an NSRange, and never truncate a composition string by slicing at an arbitrary UTF-16 boundary. Convert only at a known boundary and verify that a location is a valid Swift string index if the model requires String.Index.

Combining marks, surrogate pairs, emoji sequences, and bidirectional runs make the mismatch observable. The cursor can be between grapheme clusters only if the editing policy allows it; usually the editor should snap to valid user-perceived boundaries for navigation while still honoring the UTF-16 ranges supplied by AppKit. Write conversion helpers once, unit-test them, and do not duplicate ad hoc arithmetic throughout the view.

The attributed form of marked input can carry visual attributes such as underlines or converted-text styling. validAttributesForMarkedText() advertises which attributes the client understands. If the editor can preserve arbitrary attributes, return the appropriate keys; if not, do not claim support it does not implement. The marked-text underline must not become permanent document styling by accident when the composition commits.

Candidate-window geometry

The text input system needs to place candidate UI near the current insertion point. firstRect(forCharacterRange:actualRange:) returns the first logical boundary rectangle in screen coordinates for the requested range. The client also answers characterIndex(for:) for hit testing and baselineDeltaForCharacter(at:) for baseline placement. These geometry methods must use the same layout data and coordinate conversions as drawing and selection.

Convert from the text view’s local coordinates through its window to screen coordinates; do not return a rectangle in view space merely because that is the coordinate system used by your renderer. Handle a range that spans multiple lines by returning the first relevant rectangle and setting the actual range according to the protocol contract. If layout is not ready, force only the minimum necessary layout for that range and provide a safe fallback rather than returning stale geometry from a prior window position.

Test candidate placement at multiple display scales, with a scrolled document, a window near each screen edge, a split view, and a multiline marked range. A stale rectangle can make the candidate panel appear far from the caret even though text replacement itself is correct.

Commands are not inserted characters

The input system can ask the client to perform a command selector, such as a movement or deletion action. doCommand(by:) is not a second text insertion path. Route supported selectors into the editor’s command model and allow the normal responder chain or key-binding machinery to handle commands that the custom surface does not own. Avoid swallowing every unknown selector; doing so breaks standard editing behavior and accessibility keyboard commands.

Keep key-equivalent handling separate from text composition. A key press may be a command, a dead key, part of a marked sequence, or a committed character depending on the input source and current focus. Testing only keyDown with a US keyboard layout will not exercise the system’s composition protocol. Let AppKit’s text-input machinery interpret events and implement the client callbacks it requests.

Focus and lifecycle

The custom text view must become first responder when editing begins and resign appropriately when focus moves. Changing windows, closing a document, replacing the backing model, or disabling editing must resolve any active composition intentionally. Do not delete marked text just because the view is about to redraw. When focus is lost, use the text-input context APIs and documented client behavior rather than inventing a private key-event protocol.

If the document is read-only, expose that state to the input path so committed insertion does not modify content. A visible caret in a disabled editor can confuse both users and the input system. If the view changes its text storage during an active marked composition, preserve or explicitly cancel the marked range; a wholesale model refresh can otherwise cause the next commit to target a different object.

Operational test matrix

Test at least one input method for each supported script, a dead-key sequence, dictation if the product supports it, paste, undo immediately after commit, and composition cancellation. Include replacement of an existing selection, editing in the middle of a string containing emoji, candidate navigation, scrolling while composition is active, and focus moving to another window. Verify the final Unicode string, caret location, selection, and attributed styling, not just the screenshot.

Instrument callback order and ranges in debug builds without logging the user’s composed text. Record method name, UTF-16 range, content length, marked-state transition, and document revision. Add assertions that selected and marked ranges are within the buffer after every mutation. A bug that occurs once after a particular sequence is much easier to isolate with this trace than with a generic “IME failed” message.

Acceptance criteria

An editor is ready when marked text can be repeatedly replaced before commit, committed text replaces the intended range exactly once, cancellation leaves no stray underline, candidate UI remains anchored to the insertion point, and unknown command selectors are not destructively consumed. Run those checks on at least two input methods and a non-US keyboard layout in addition to ordinary Latin typing.

The important abstraction is that an input method collaborates with the editor over a changing text range. Implement that collaboration as explicit state, preserve UTF-16 and coordinate contracts, and prefer NSTextView when the application does not need to own a custom text client.

Related:

Sources:

Comments