Skip to content
PressboardKit
AutocompleteBrowse docs

Guides

Autocomplete

The suggestion provider protocol, the bundled dictionary, autocorrection through a layered spell checker, next-word prediction, and slide-to-type.

PressboardKitAutocomplete is an optional product. Link it and the suggestion bar, autocorrection and slide-to-type all work from the bundled English and Czech frequency lists; leave it out and the engine still types, it just has nothing to suggest.

The provider protocol

One method, and it runs on every keystroke:

public protocol SuggestionProvider: Sendable {
    func suggestions(for textBeforeCursor: String) -> [Suggestion]
}

public struct Suggestion: Equatable, Sendable {
    public var text: String
    public var isAutocorrect: Bool
    public var isUnknown: Bool

    public init(text: String, isAutocorrect: Bool = false, isUnknown: Bool = false)
}

The provider finds the current word inside the text itself — SuggestionText.currentWord(in:) is the same helper the engine uses. isAutocorrect marks the highlighted middle slot; isUnknown marks the “keep what I typed” slot native shows in quotes.

The bundled provider

let provider = DictionarySuggestionProvider(words: myWords)     // frequency-ordered
let english  = DictionarySuggestionProvider.english             // the bundled list
let czech    = DictionarySuggestionProvider.czech

Multi-language is a separate initialiser, because how the lists are ranked matters:

DictionarySuggestionProvider(languageLists: [czechWords, englishWords])

Beyond suggestions(for:) it exposes the pieces the rest of the engine needs: isViablePrefix(_:) and isViablePrefix(_:followedBy:) for predictive key targeting, candidateWords and candidateWordsRanked for the swipe decoder, rank(of:), mostFrequentWord(folded:), diacriticRestoration(of:) and openingWords.

It indexes its word list, so suggestions(for:) stays sub-millisecond. If you write your own provider, keep that call just as cheap — it is on the typing path.

Rendering the bar

The native three-slot bar is SuggestionBar, and it goes in the view’s toolbar slot:

PressboardKeyboardView(
    layout: model.layout,
    behavior: model.behavior,
    onAction: handle,
    toolbar: {
        if model.behavior.predictiveText {
            SuggestionBar(suggestions: model.suggestions, onPick: applyPick)
        }
    })

Applying a pick replaces the current word:

func applyPick(_ suggestion: Suggestion) {
    let before = textDocumentProxy.documentContextBeforeInput ?? ""
    let word = SuggestionText.currentWord(in: before)
    for _ in 0..<word.count { textDocumentProxy.deleteBackward() }
    textDocumentProxy.insertText(suggestion.text + " ")
    sync()
}

SuggestionBar.height is what you add to the keyboard’s height when the bar is shown — see Performance.

The bar is shared with two other sources, so size and show it when any of predictiveText, showMathResults or emojiSuggestions is on — otherwise those have nowhere to render.

Autocorrection

Autocorrection runs on space. It takes the word before the cursor and a spell checker:

AutoCorrection.correction(
    for: word,
    using: checker,
    locale: context.locale,
    minimumWordLength: behavior.autoCorrectMinimumWordLength,
    enabled: behavior.autoCorrection)

autoCorrectMinimumWordLength defaults to 2 because native never corrects a lone character — “Add 100 g of flour” must not become “100 G”. The locale rules in AutoCorrection.explicitCorrection (English i to I) are exempt from the length guard, and tokens containing digits are never corrected at all.

suppressAutocorrectAfterEdit, on by default, is the “I rejected that correction” behaviour: once a word has been auto-corrected, backspacing into it stops it from being corrected again until the user moves on.

The layered spell checker

Use LayeredSpellChecker unless you have a reason not to. It is what PressboardController builds:

LayeredSpellChecker(
    language: locale.identifier,   // "cs" — resolved to the checker's own "cs_CZ"
    provider: provider,            // the merged dictionary of every enabled language
    neighbors: KeyNeighbors(rows: CharacterRows.rows(for: locale, layoutType: layoutType)))

It layers three sources, because UITextChecker is not all-or-nothing per language: its misspelling finder and its guesses ship independently. On a Czech phone the finder never flags a word — not even an obvious typo — while guesses answers correctly. Ask only through the finder, which is the obvious reading of the API, and Czech gets nothing.

  1. SystemSpellChecker.correction(for:) where flagsMisspellings is true. The system’s verdict, taken as is, including when it declines to correct.
  2. Otherwise SystemSpellChecker.guesses(for:), behind two guards that stand in for the missing finder: the word must be one that knowsWord(_:) does not know, and the guess must be a letter on the wrong key or two letters in the wrong order. Both are load-bearing — without the first, the real Czech word “vrčel” becomes “vrčeli”; without the second, “codelas” becomes “odělas”.
  3. Otherwise DictionarySpellChecker, correcting from the bundled word list. Guesses that add or drop a letter land here rather than in step 2, so they only apply when our own list carries them.

Every piece is public if you want to assemble them differently. SystemSpellChecking is the protocol — flagsMisspellings, guesses(for:), knowsWord(_:) — and LayeredSpellChecker(system:provider:neighbors:) takes any implementation of it, including a fake, on any platform. SystemSpellChecker(language:) is the UITextChecker-backed one and exposes resolvedLanguage, because a bare "cs" is not one of its languages: it names them cs_CZ and matches literally. TypingSlip.between(typed:intended:neighbors:) is the slip test, and KeyNeighbors is built from the rows actually on screen.

Next-word prediction

The provider completes the word being typed. This is the other half: after “see you” the bar should read “later / tomorrow / soon” rather than nothing.

PressboardConfiguration(
    nextWordPredictor: NextWordPredictor(seed: ["see": ["you", "ya"]]),
    loadLearnedWordPairs: { MyStore.learnedPairs },
    saveLearnedWordPairs: { MyStore.learnedPairs = $0 })

Those two hooks are close to required in practice. A keyboard extension is killed constantly, and without persistence the predictor forgets everything every few minutes and never becomes useful.

NextWordPredictor merges two sources and always ranks learned pairs above the seed table. It learns a pair whenever a word is finished with a space or a return, using the text after the separator landed — so it learns what the user kept, not what autocorrection replaced. The learned table is bounded by maxLearnedContexts, evicting the oldest context first, and by maxFollowersPerContext, so it cannot grow into the extension’s memory budget.

When nothing is known for the preceding word it returns nothing and the bar stays empty. Offering the most frequent words of the language after every space is noise, not prediction.

Bring your own model by conforming to NextWordPredicting — two methods, nextWords(after:limit:) and learn(_:after:). It is kept off the per-keystroke completion path on purpose, so a network-backed model cannot slow typing down.

Empty fields, arithmetic and emoji

Three smaller sources share the same bar.

  • emptyFieldSuggestions offers a few words in a field nobody has typed into yet, as native does. The words come from DictionarySuggestionProvider.openingWords, which defaults to the primary language’s three most frequent; pass your own through init(words:openingWords:).
  • showMathResults offers the result of a typed arithmetic expression. MathResult.suggestionVariants(_:) builds the native three slots — the expression, the highlighted expression=result, and the result alone — variantTexts(forTextBeforeCursor:) lets you recognise a picked one, and expressionLength(forTextBeforeCursor:) tells you how much to replace.
  • emojiSuggestions offers the emoji for the word being typed — EmojiSuggestion.emoji(forTextBeforeCursor:). Bilingual, case- and diacritic-insensitive; picking it replaces the current word like any other suggestion.

Slide to type

slideToType is on by default. The engine detects a glide across three or more letters and emits two actions:

case .slideChanged(let keys):
    let candidates = SwipeDecoder.decodeCandidates(
        keySequence: keys,
        candidates: provider.candidateWordsRanked,
        limit: 3)
    model.suggestions = candidates.enumerated().map {
        Suggestion(text: $1, isAutocorrect: $0 == 0)
    }

case .slideTyped(let keys):
    // decode, insert the best word plus a space, keep the alternatives in the bar

Use candidateWordsRanked, not candidateWords. On a multi-language provider candidateWords is one flat concatenated array with the primary language first, so a plain frequency-ordered decode would let the first-listed language’s words outrank every other enabled language’s regardless of actual frequency. candidateWordsRanked pairs each word with its rank inside its own language, so a very common Czech word can beat a rare English one even on an English-primary keyboard.

Matching is diacritic-insensitive, because a glide only ever crosses a keyboard’s base-letter keys — diacritics are a long-press callout, never something you swipe through. A Czech glide over “m”, “a”, “s” therefore matches the dictionary entry “máš”. What gets inserted is decided by slideToTypeRestoresDiacritics, which defaults to true. This is a deliberate divergence from native: measured on a device, native inserts the folded “mas”. Restoring the accents costs nothing once the decoder has already found that exact entry, and “máš” is the correct word. Set it to false to reproduce native exactly.

Next: Emoji.

Edit this page on GitHub