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.
SystemSpellChecker.correction(for:)whereflagsMisspellingsis true. The system’s verdict, taken as is, including when it declines to correct.- Otherwise
SystemSpellChecker.guesses(for:), behind two guards that stand in for the missing finder: the word must be one thatknowsWord(_:)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”. - 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.
emptyFieldSuggestionsoffers a few words in a field nobody has typed into yet, as native does. The words come fromDictionarySuggestionProvider.openingWords, which defaults to the primary language’s three most frequent; pass your own throughinit(words:openingWords:).showMathResultsoffers the result of a typed arithmetic expression.MathResult.suggestionVariants(_:)builds the native three slots — the expression, the highlightedexpression=result, and the result alone —variantTexts(forTextBeforeCursor:)lets you recognise a picked one, andexpressionLength(forTextBeforeCursor:)tells you how much to replace.emojiSuggestionsoffers 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.