Guides
Keyboard actions
Every user intent the engine reports, what it means, and how a host turns it into an edit on the text document.
The render view never touches the text document. It draws the layout and calls onAction with a
KeyboardAction describing what the user did; the host decides what that means. That split is
what keeps the engine host-agnostic, and it is also the seam where you intercept anything.
If you subclass PressboardInputViewController, PressboardController.handle(_:) already does
all of this. Read this page when you want to intercept a specific action, or when you are
assembling the keyboard yourself.
The action type
public enum KeyboardAction: Equatable, Sendable {
case character(String)
case backspace
case deleteWord
case shift
case space
case primary
case keyboardType(KeyboardType)
case nextKeyboard
case dictation
case custom(named: String)
case moveCursor(offset: Int)
case moveCursorSentence(Int)
case slideTyped(keys: [Character])
case slideChanged(keys: [Character])
case none
}
What each one means
| Action | Meaning | Typical handling |
|---|---|---|
.character(String) |
A key or a long-press callout produced text. | Run the punctuation helpers, then insertText. |
.space |
The space bar. | Autocorrect the finished word, then the double-space period shortcut, then insert. |
.backspace |
A backspace tap. | deleteBackward(). |
.deleteWord |
A backspace hold that escalated past single characters. | Delete a whole word with DeleteByWord. |
.primary |
Return, Go, Search, Send, the tick. | Insert "\n", or close emoji search. |
.shift |
Shift tapped. | Run ShiftLogic.afterShiftTap and re-resolve the layout. |
.keyboardType(KeyboardType) |
A page switch: 123, #+=, ABC, emoji. |
Set context.keyboardType and re-resolve. |
.moveCursor(offset:) |
A spacebar drag moved the caret horizontally. | adjustTextPosition(byCharacterOffset:). |
.moveCursorSentence(_:) |
A spacebar drag moved vertically. Negative is backwards. | Convert to a character offset with SpacebarDragLogic.characterOffset, then adjust. |
.slideChanged(keys:) |
A glide is in progress. | Preview candidates in the bar. Do not insert. |
.slideTyped(keys:) |
The glide finished. | Decode and insert the word. |
.nextKeyboard |
The globe key. | advanceToNextInputMode(). |
.dictation |
The mic key. | Start your own speech-to-text. |
.custom(named:), .none |
A host-defined key, or a spacer. | Yours, or ignore. |
.moveCursorSentence steps between sentence boundaries — the start of the text, after a run of
. ! ? …, after a newline, and the end — rather than between rendered lines. A text
document proxy exposes no text geometry and only a truncated window of the document, so the
positions where lines actually wrap are not knowable from inside a keyboard extension.
A complete router
func handle(_ action: KeyboardAction) {
switch action {
case .character(let text):
insertCharacter(text)
case .space:
insertSpace()
case .backspace:
textDocumentProxy.deleteBackward()
case .deleteWord:
deleteWord()
case .primary:
textDocumentProxy.insertText("\n")
case .shift:
context.keyboardCase = ShiftLogic.afterShiftTap(
current: context.keyboardCase,
sinceLastShiftTap: sinceLastShiftTap,
capsLockEnabled: behavior.enableCapsLock)
resolveLayout()
case .keyboardType(let type):
context.keyboardType = type
resolveLayout()
case .moveCursor(let offset):
textDocumentProxy.adjustTextPosition(byCharacterOffset: offset)
case .moveCursorSentence(let rows):
let offset = SpacebarDragLogic.characterOffset(
forSentences: rows,
before: textDocumentProxy.documentContextBeforeInput ?? "",
after: textDocumentProxy.documentContextAfterInput ?? "")
textDocumentProxy.adjustTextPosition(byCharacterOffset: offset)
case .slideChanged(let keys):
previewGlide(keys)
case .slideTyped(let keys):
insertGlide(keys)
case .nextKeyboard:
advanceToNextInputMode()
case .dictation:
startDictation()
case .custom, .none:
break
}
}
The text helpers
Each text-level behaviour is a pure function that takes its own enabled: flag and no-ops when
it is off, so you can wire them all unconditionally and let KeyboardBehavior decide.
Inserting a character
Two transforms compete for the same keystroke: moving a stray space to after a punctuation mark, and turning straight quotes into curly ones. Try the spacing fixup first.
func insertCharacter(_ text: String) {
let before = textDocumentProxy.documentContextBeforeInput ?? ""
guard let character = text.first, text.count == 1 else {
textDocumentProxy.insertText(text)
return
}
if let fixed = PunctuationSpacing.fixup(for: character,
textBeforeCursor: before,
enabled: behavior.punctuationSpacing) {
textDocumentProxy.deleteBackward()
textDocumentProxy.insertText(fixed)
} else if let result = SmartPunctuation.transform(for: character,
textBeforeCursor: before,
locale: context.locale,
enabled: behavior.smartPunctuation) {
switch result {
case .insert(let string):
textDocumentProxy.insertText(string)
case .replaceLast(let string):
textDocumentProxy.deleteBackward()
textDocumentProxy.insertText(string)
}
} else {
textDocumentProxy.insertText(text)
}
context.keyboardType = LayoutAutoSwitch.nextType(
after: character, on: context.keyboardType,
enabled: behavior.returnToLettersAfterSpace)
sync()
}
Inserting a space
The space key is where autocorrection fires, and where the double-space period shortcut lives.
sinceLastSpace is the time since the user’s previous space keystroke, or nil if there was
none — the shortcut only expands inside periodShortcutWindow, as native does, so a space that
was pasted or typed a minute ago is left alone.
func insertSpace() {
let before = textDocumentProxy.documentContextBeforeInput ?? ""
if let fix = autocorrection(forTextBeforeCursor: before) {
let word = SuggestionText.currentWord(in: before)
for _ in 0..<word.count { textDocumentProxy.deleteBackward() }
textDocumentProxy.insertText(fix)
}
let now = textDocumentProxy.documentContextBeforeInput ?? ""
if PeriodShortcut.shouldExpand(textBeforeCursor: now,
sinceLastSpace: sinceLastSpace,
window: behavior.periodShortcutWindow,
enabled: behavior.periodShortcut) {
textDocumentProxy.deleteBackward()
textDocumentProxy.insertText(". ")
} else {
textDocumentProxy.insertText(" ")
}
sync()
}
Deleting a word
func deleteWord() {
let before = textDocumentProxy.documentContextBeforeInput ?? ""
let count = DeleteByWord.wordDeletionLength(textBeforeCursor: before)
for _ in 0..<count { textDocumentProxy.deleteBackward() }
sync()
}
Keeping the case right
After every edit, ask whether the next letter should be capitalised and re-resolve the layout if the answer changed.
let shouldCapitalize = AutoCapitalization.shouldCapitalizeNext(
afterTextBeforeCursor: before,
enabled: behavior.autoCapitalization)
ShiftLogic.afterCharacterInput(current:) gives you the case after a character was typed — it is
what drops a single shift back to lowercase while leaving caps lock engaged.
The spacebar cursor drag
PressboardKeyboardView also reports the drag itself through onCursorMove, called with true
when a spacebar press becomes a caret drag and false when it ends. The view already blanks every
key for the duration, which is what native does. Use the callback to freeze your suggestions and
your keyboard case while the caret is moving, and to run one resynchronisation on false for the
new caret position.
Two parameters matter here. cursorDragUpdateInterval coalesces caret pushes to the host, because
every push is a cross-process call and a ProMotion display produces around 120 touch events a
second — at that rate the host’s text view never gets an idle moment to repaint the caret, and it
visibly disappears mid-drag. cursorDragEngageHaptic fires the tick that tells the user they are
now positioning rather than typing; it fires even when haptic feedback is otherwise off, because it
marks a mode change rather than a keystroke.
Key-down input
With insertOnKeyDown on, a character — and the space bar — commits on press rather than on
release. A long-press callout replaces the committed character, a glide undoes it, and dragging
off the key cancels it. A spacebar press that turns into a cursor drag undoes its space instead of
typing one.
spaceCommitsOnRelease (on by default, which is native) holds the space back until lift-off, so a
press can still turn out to be a drag rather than typing a space and deleting it again. Ordering
is preserved either way: a held space is flushed the instant another key commits.
Next: Theming.