Skip to content
CleverKeys Wiki
implemented development User guide

Per-Key Actions Technical Specification

Overview

Short Swipe Customization allows users to fully customize the 8-direction swipe gestures for every key on the keyboard. Each key has 8 subkey positions (N, NE, E, SE, S, SW, W, NW) that can be customized with text input, commands, key events, or Android Intents through a dedicated settings UI.

Key Files

FileClass/FunctionPurpose
src/main/kotlin/tribixbite/cleverkeys/customization/SwipeDirection.ktSwipeDirectionEnum for 8 directions
src/main/kotlin/tribixbite/cleverkeys/customization/ActionType.ktActionTypeEnum for action types
src/main/kotlin/tribixbite/cleverkeys/customization/ShortSwipeMapping.ktShortSwipeMappingData model for custom mapping
src/main/kotlin/tribixbite/cleverkeys/customization/ShortSwipeCustomizationManager.ktShortSwipeCustomizationManagerJSON persistence, CRUD operations
src/main/kotlin/tribixbite/cleverkeys/customization/CustomShortSwipeExecutor.ktCustomShortSwipeExecutorExecutes commands via InputConnection
src/main/kotlin/tribixbite/cleverkeys/customization/IntentDefinition.ktIntentDefinition, IntentTargetTypeIntent data model, presets, Gson-safe parsing
src/main/kotlin/tribixbite/cleverkeys/customization/IntentEditorDialog.ktIntentEditorDialogUI for configuring intents (presets, manual fields, extras)
src/main/kotlin/tribixbite/cleverkeys/customization/CommandRegistry.ktCommandRegistry200+ searchable commands
src/main/kotlin/tribixbite/cleverkeys/customization/XmlAttributeMapper.ktXmlAttributeMapperXML round-trip for all action types incl. INTENT
src/main/kotlin/tribixbite/cleverkeys/KeyValueParser.ktparseIntentKeydef()Parses intent:'json' from layout XML
src/main/kotlin/tribixbite/cleverkeys/Pointers.kthandleShortGesture()Checks custom mappings first
src/main/kotlin/tribixbite/cleverkeys/KeyEventHandler.ktIntegrationExecutes custom commands

Architecture

Settings UI
       |
       v
+----------------------------------+
| ShortSwipeCustomization          | -- User selects key, direction, action
| Activity                         |
+----------------------------------+
       |
       v
+----------------------------------+
| ShortSwipeCustomization          | -- CRUD for mappings
| Manager                          | -- JSON persistence
+----------------------------------+
       |
       v (on gesture)
+----------------------------------+
| Pointers.handleShortGesture()    | -- Check custom mapping first
+----------------------------------+
       |
       v (if custom found)
+----------------------------------+
| CustomShortSwipeExecutor         | -- Execute TEXT/COMMAND/KEY_EVENT/INTENT
| .execute()                       |
+----------------------------------+
       |
       v (if INTENT)
+----------------------------------+
| IntentDefinition.parseFromGson() | -- Null-safe JSON deserialization
| validateIntent()                 | -- URI scheme, package existence
| context.startActivity/Service/   | -- Dispatch by IntentTargetType
|   sendBroadcast                  |
+----------------------------------+

Data Model

SwipeDirection

enum class SwipeDirection {
    N,   // North (up)
    NE,  // Northeast
    E,   // East (right)
    SE,  // Southeast
    S,   // South (down)
    SW,  // Southwest
    W,   // West (left)
    NW   // Northwest
}

ActionType

ActionType persists the stable enum names TEXT, COMMAND, KEY_EVENT, INTENT, and TIMESTAMP. Its user-visible labels and descriptions are string resources (displayNameRes and descriptionRes), resolved at the UI boundary. Literal TEXT does not interpret a timestamp pattern or a dynamic template.

ShortSwipeMapping

data class ShortSwipeMapping(
    val keyCode: String,           // Key identifier (e.g., "a", "e", "shift")
    val direction: SwipeDirection, // One of 8 directions
    val displayText: String,       // Max 4 chars for visual display
    val actionType: ActionType,    // TEXT, COMMAND, KEY_EVENT, or INTENT
    val actionValue: String,       // Text content, command name, keycode, or JSON IntentDefinition
    val useKeyFont: Boolean = false // Use special_font.ttf for icons
)

Storage Format

File: short_swipe_customizations.json

{
  "version": 2,
  "mappings": {
    "a": {
      "N": { "displayText": "@", "actionType": "TEXT", "actionValue": "@", "useKeyFont": false },
      "NE": { "displayText": "sel", "actionType": "COMMAND", "actionValue": "select_all", "useKeyFont": false }
    },
    "e": {
      "NW": { "displayText": "", "actionType": "COMMAND", "actionValue": "home", "useKeyFont": true }
    },
    "t": {
      "N": {
        "displayText": "term",
        "actionType": "INTENT",
        "actionValue": "{\"name\":\"Termux Command\",\"targetType\":\"SERVICE\",\"action\":\"com.termux.RUN_COMMAND\",\"packageName\":\"com.termux\",\"className\":\"com.termux.app.RunCommandService\",\"extras\":{\"com.termux.RUN_COMMAND_PATH\":\"/data/data/com.termux/files/usr/bin/echo\",\"com.termux.RUN_COMMAND_ARGUMENTS\":\"Hello\",\"com.termux.RUN_COMMAND_BACKGROUND\":\"true\"}}",
        "useKeyFont": false
      }
    },
    "d": { "N": { "displayText": "date", "actionType": "TIMESTAMP", "actionValue": "yyyy-MM-dd", "useKeyFont": false } }
  }
}

Available Commands

CommandRegistry contains 200+ commands in 21 categories:

Core Categories

CategoryExample Commands
CLIPBOARDcopy, copy_private, clear_clipboard, paste, cut, pasteAsPlainText
EDITINGundo, redo, clear
CURSORcursor_left, cursor_right, home, end
NAVIGATIONpage_up, page_down, doc_home, doc_end
SELECTIONselection_cursor_left, selection_cursor_right, selection_cancel
DELETEdelete_word, forward_delete_word
MODIFIERSshift, ctrl, alt, meta, fn
FUNCTION_KEYSf1-f12
SPECIAL_KEYSescape, tab, insert, print_screen
EVENTSconfig, change_method, action, caps_lock
MEDIAmedia_play_pause, volume_up, volume_down
SYSTEMsearch, calculator, calendar, brightness_up

Diacritics Categories

CategoryExample Commands
DIACRITICScombining_grave, combining_acute
DIACRITICS_SLAVONICcombining_titlo, combining_palatalization
DIACRITICS_ARABICarabic_fatha, arabic_kasra, arabic_sukun
HEBREWhebrew_dagesh, hebrew_qamats, hebrew_tsere

Public API

ShortSwipeCustomizationManager

class ShortSwipeCustomizationManager(context: Context) {
    // Get mapping for specific key and direction
    fun getMapping(keyCode: String, direction: SwipeDirection): ShortSwipeMapping?

    // Save or update a mapping
    fun saveMapping(mapping: ShortSwipeMapping)

    // Delete a mapping
    fun deleteMapping(keyCode: String, direction: SwipeDirection)

    // Get all mappings for a key
    fun getMappingsForKey(keyCode: String): Map<SwipeDirection, ShortSwipeMapping>

    // Reset all customizations
    fun resetAll()

    // Export for backup
    fun exportToJson(): String

    // Import from backup
    fun importFromJson(json: String)
}

CustomShortSwipeExecutor

class CustomShortSwipeExecutor(
    private val inputConnection: InputConnection,
    private val keyEventHandler: KeyEventHandler
) {
    fun execute(mapping: ShortSwipeMapping, inputConnection: InputConnection?, editorInfo: EditorInfo?): Boolean {
        return when (mapping.actionType) {
            ActionType.TEXT -> executeTextInput(mapping.actionValue, inputConnection)
            ActionType.COMMAND -> executeCommandByName(mapping.actionValue, inputConnection, editorInfo)
            ActionType.KEY_EVENT -> executeKeyEvent(mapping.getKeyEventCode(), inputConnection)
            ActionType.INTENT -> executeIntent(mapping.actionValue)
            ActionType.TIMESTAMP -> executeTimestamp(mapping.actionValue, inputConnection)
        }
    }
}

Single-apostrophe text actions

For TEXT mappings whose entire value is ASCII apostrophe or U+2019, Keyboard2View uses the ordinary KeyEventHandler route with empty modifiers before invoking the custom executor. Smart Punctuation can then reclaim only a verified automatic space; manual whitespace and opening-quote behavior retain ordinary-key semantics. The same route supports inline search/edit fields and typed-text bookkeeping. Successful actions retain the normal custom-swipe haptic.

Other text macros remain literal. A multi-character apostrophe-s macro is not a possessive transaction and does not provide suffix-only undo. Explicit suffix commands require separate verified attachment and undo state; see the October 5 feature roadmap.

Timestamp Action Type

The TIMESTAMP action type formats the current Date with a SimpleDateFormat pattern stored in actionValue and commits the result via InputConnection.commitText. The current system default Locale is used.

private fun executeTimestamp(pattern: String, ic: InputConnection?): Boolean {
    if (ic == null) return false
    return try {
        val formatter = SimpleDateFormat(pattern, Locale.getDefault())
        val formatted = formatter.format(Date())
        ic.commitText(formatted, 1)
    } catch (e: IllegalArgumentException) {
        // Malformed pattern (e.g. unclosed quote, unknown letter)
        false
    } catch (e: Exception) {
        false
    }
}

Invalid patterns are caught and result in a no-op (false return). Preset chips in CommandPaletteDialog provide the common patterns (yyyy-MM-dd, HH:mm, yyyy-MM-dd HH:mm, ISO 8601, etc.); users can also enter any custom pattern.

Terminal-Aware Paste

Both the AvailableCommand.PASTE and CommandRegistry “paste” paths use handlePaste(). For terminal targets (built-in detection plus Settings > Advanced > Custom terminal packages) it reads the system clip and commits its text directly; other editors keep performContextMenuAction(android.R.id.paste):

// CustomShortSwipeExecutor.kt
private fun handlePaste(inputConnection: InputConnection, editorInfo: EditorInfo?): Boolean {
    return if (TerminalUtils.isTerminalApp(editorInfo, Config.globalConfigOrNull()?.custom_terminal_packages)) {
        pasteFromSystemClipboard(inputConnection)
    } else {
        inputConnection.performContextMenuAction(android.R.id.paste)
    }
}

pasteFromSystemClipboard coerces the first clip item to text and calls commitText; a null or empty clip inserts nothing. KeyEventHandler.handlePaste() applies the same rule to the regular paste key. Terminals usually implement neither the context-menu paste nor a Ctrl+V key event through the IME, which is why an earlier Ctrl+V approach was replaced.

Pinned Clipboard Commands (paste_pinned_N)

Five commands (paste_pinned_1 through paste_pinned_5) allow direct insertion of pinned clipboard entries by position, without opening the clipboard panel.

CommandRegistry registration:

// Category.CLIPBOARD entries in CommandRegistry
Command("paste_pinned_1", "Paste Pin #1", "Insert 1st pinned clipboard entry", Category.CLIPBOARD,
    keywords = listOf("pin", "pinned", "clipboard", "paste", "1", "first")),
// ... through paste_pinned_5

Execution in CustomShortSwipeExecutor:

private fun executePastePinned(commandName: String, inputConnection: InputConnection): Boolean {
    val index = commandName.removePrefix("paste_pinned_").toIntOrNull()
    if (index == null || index < 1) return false

    val db = ClipboardDatabase.getInstance(context)
    val pinnedEntries = db.getPinnedEntries()

    if (index > pinnedEntries.size) {
        showToast("Pinned entry #$index not found (${pinnedEntries.size} pinned)")
        return false
    }

    val entry = pinnedEntries[index - 1]
    inputConnection.commitText(entry.content, 1)
    return true
}

Key implementation details:

  • Uses ClipboardDatabase.getInstance(context) singleton to access the clipboard database
  • getPinnedEntries() returns entries ordered by timestamp DESC (most recently pinned first)
  • Index is 1-based in the command name, converted to 0-based for list access
  • Graceful failure: shows a toast with the actual pinned count if the index exceeds available entries
  • All exceptions caught and surfaced as a toast (“Failed to read pinned entry”), never crashes

Text Limit (MAX_ACTION_LENGTH)

The actionValue field on ShortSwipeMapping is capped at MAX_ACTION_LENGTH (4096 characters). This limit applies to all action types — TEXT input content, COMMAND names, KEY_EVENT codes, and serialized INTENT JSON.

companion object {
    const val MAX_ACTION_LENGTH = 4096
}

The previous limit was 100 characters, which was insufficient for long text snippets, multi-line templates, and serialized intent JSON payloads.

Paste Button in Custom Text Input

The CommandPaletteDialog text input field includes a trailing paste IconButton. This exists because Jetpack Compose Dialog does not reliably receive performContextMenuAction(android.R.id.paste) from the IME — the paste action is silently dropped.

The workaround reads directly from ClipboardManager:

trailingIcon = {
    IconButton(onClick = {
        val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
        val clip = clipboard?.primaryClip
        if (clip != null && clip.itemCount > 0) {
            val pasteText = clip.getItemAt(0).coerceToText(context).toString()
            if (pasteText.isNotEmpty()) {
                val combined = text + pasteText
                onTextChange(combined.take(ShortSwipeMapping.MAX_ACTION_LENGTH))
            }
        }
    }) {
        Icon(imageVector = Icons.Filled.Create, contentDescription = "Paste from clipboard")
    }
}

The pasted content is appended to existing text and truncated to MAX_ACTION_LENGTH.

CommandRegistry

object CommandRegistry {
    // Get all commands grouped by category
    fun getAllCommands(): Map<Category, List<Command>>

    // Search commands by keyword
    fun search(query: String): List<Command>

    // Get display info (icon + useKeyFont flag)
    fun getDisplayInfo(commandName: String): DisplayInfo?

    data class Command(
        val name: String,
        val category: Category,
        val keywords: List<String>,
        val description: String
    )
}

Intent Action Type

The INTENT action type allows users to fire Android Intents directly from a swipe gesture. This enables launching apps, starting services, sending broadcasts, and integrating with automation tools like Termux.

IntentDefinition

data class IntentDefinition(
    val name: String = "",                        // Human-readable label
    val targetType: IntentTargetType = ACTIVITY,  // ACTIVITY, SERVICE, or BROADCAST
    val action: String? = null,                   // e.g., "android.intent.action.VIEW"
    val data: String? = null,                     // URI, e.g., "https://google.com"
    val type: String? = null,                     // MIME type, e.g., "text/plain"
    val packageName: String? = null,              // Target package
    val className: String? = null,                // Target component class
    val extras: Map<String, String>? = null       // Key-value extras (String values only)
)

enum class IntentTargetType {
    ACTIVITY,   // Start an activity (most common)
    SERVICE,    // Start a foreground/background service
    BROADCAST   // Send a broadcast intent
}

Gson-Safe Parsing

Gson uses sun.misc.Unsafe to instantiate Kotlin data classes, bypassing the constructor. This means non-nullable fields with Kotlin defaults (name: String = "") become null when the JSON key is absent. IntentDefinition.parseFromGson() re-applies Kotlin defaults after deserialization:

companion object {
    fun parseFromGson(json: String): IntentDefinition? {
        val raw = Gson().fromJson(json, IntentDefinition::class.java) ?: return null
        return IntentDefinition(
            name = raw.name ?: "",
            targetType = raw.targetType ?: IntentTargetType.ACTIVITY,
            // nullable fields pass through unchanged
            action = raw.action, data = raw.data, type = raw.type,
            packageName = raw.packageName, className = raw.className,
            extras = raw.extras
        )
    }
}

Intent Validation

Before execution, CustomShortSwipeExecutor.validateIntent() checks:

CheckRule
Action or packageMust have at least one of action or packageName
Package installedIf packageName set, verify via PackageManager.getPackageInfo()
URI schemeIf data set, Uri.parse() result must have a non-blank scheme

Note: Uri.parse() on Android never throws; it silently produces an opaque URI. The scheme check catches malformed URIs like "not a uri".

Intent Execution Flow

When both data and type are set, Intent.setDataAndType() must be used. Calling Intent.setData() clears the type and vice versa — this is an Android API pitfall that was caught in code review.

when {
    hasData && hasType -> intent.setDataAndType(Uri.parse(data), type)
    hasData -> intent.data = Uri.parse(data)
    hasType -> intent.type = type
}

Dispatch by target type:

  • ACTIVITY: context.startActivity(intent) with FLAG_ACTIVITY_NEW_TASK. Pre-checks resolveActivity() unless an explicit component is set.
  • SERVICE: context.startService(intent).
  • BROADCAST: context.sendBroadcast(intent).

Intent Presets (11 built-in)

PresetTargetActionUse Case
Open BrowserACTIVITYVIEWOpen URL in browser
Share TextACTIVITYSENDShare text via share sheet
Dial PhoneACTIVITYDIALOpen phone dialer
Send EmailACTIVITYSENDTOOpen email composer
Open SettingsACTIVITYSETTINGSSystem settings
Wi-Fi SettingsACTIVITYWIFI_SETTINGSWi-Fi settings page
Bluetooth SettingsACTIVITYBLUETOOTH_SETTINGSBluetooth settings page
Open CameraACTIVITYIMAGE_CAPTURELaunch camera
Open MapsACTIVITYVIEW (geo:)Open maps with query
Web SearchACTIVITYWEB_SEARCHWeb search
Termux CommandSERVICERUN_COMMANDExecute Termux CLI command

XML Round-Trip (Layout Import/Export)

Intent mappings stored in layout XML use the __intent__: prefix:

intent:'{"name":"Open Browser","action":"android.intent.action.VIEW","data":"https://google.com"}'

KeyValueParser.parseIntentKeydef() reads this format and produces a KeyValue string with the IntentDefinition.INTENT_PREFIX (__intent__:) prepended. Profile import/export strips this prefix for round-trip compatibility.

XmlAttributeMapper handles serialization of all action types to/from XML attributes, including proper single-quote escaping for TEXT values.

ShortSwipeMapping Factory & Accessor

// Factory method
ShortSwipeMapping.intent(keyCode, direction, displayText, intentJson, useKeyFont)

// Accessor — uses parseFromGson for null safety
fun getIntentDefinition(): IntentDefinition? {
    return if (actionType == ActionType.INTENT) {
        IntentDefinition.parseFromGson(actionValue)
    } else null
}

The actionValue for INTENT mappings is a JSON string capped at MAX_ACTION_LENGTH (4096 chars).

Implementation Details

Integration with Pointers.kt

Custom mappings are checked before built-in subkeys:

private fun handleShortGesture(ptr: Pointer, direction: SwipeDirection) {
    // Check custom mapping first
    val customMapping = customizationManager.getMapping(ptr.key.name, direction)
    if (customMapping != null) {
        customExecutor.execute(customMapping)
        return
    }

    // Fall back to built-in subkey
    val subkey = ptr.key.getSubkey(direction)
    if (subkey != null) {
        handleKeyPress(subkey)
    }
}

Modifier Key Support

Custom mappings work even with Shift/Ctrl/Alt active:

private fun shouldBlockBuiltInGesture(): Boolean {
    // Built-in gestures blocked when modifiers active
    return modifierState != 0
}

// But custom mappings always execute
if (customMapping != null) {
    customExecutor.execute(customMapping)  // Executes regardless of modifiers
    return
}

// Built-in check happens after
if (shouldBlockBuiltInGesture()) {
    return  // Block built-in subkey
}

Icon Font Rendering

Custom mappings can use special_font.ttf for icon display:

// In Keyboard2View
private fun drawCustomSubLabel(canvas: Canvas, mapping: ShortSwipeMapping, x: Float, y: Float) {
    val paint = if (mapping.useKeyFont) {
        sublabelPaint.apply { typeface = specialFont }
    } else {
        sublabelPaint.apply { typeface = Typeface.DEFAULT }
    }
    canvas.drawText(mapping.displayText, x, y, paint)
}

Direction Zone Colors (UI)

The customization UI uses distinct colors for each direction:

DirectionColor
NWRed (#FF6B6B)
NTeal (#4ECDC4)
NEYellow (#FFE66D)
WMint (#95E1D3)
ECoral (#F38181)
SWPurple (#AA96DA)
SCyan (#72D4E8)
SEPink (#FCBAD3)

Performance

  • Custom mapping lookup: < 1ms (HashMap)
  • UI response time: < 16ms (60fps)
  • JSON storage load: < 100ms

October 6 routing regressions

A custom single ASCII or curly apostrophe uses ordinary key punctuation routing. With readable selection data, it can reclaim an owned automatic space only at a collapsed selection with a matching cursor stamp. Reported ranges and manual spaces are preserved; editors without selection data retain the existing text/ownership fallback. Multi-character TEXT actions remain literal. SmartAutoSpaceTest and Keyboard2ViewCustomSwipeDispatchTest protect these boundaries.

PointersGestureRoutingTest loads an actual persisted custom mapping on a cold start with swipe typing disabled, protecting GH #145 without requiring a model load. Full validation is recorded in testing strategy. Explicit suffix transactions and dynamic templates are implemented below.

Explicit dynamic templates (October 6 development work)

ActionType.TEMPLATE and KeyValue.Kind.Template hold a validated template payload. DynamicTemplate parses doubled brace escapes and the four fixed tokens in one pass. Provider values are memoized per invocation and never parsed recursively. Bounds are UTF-16 units: 4,096 input, 65,536 expanded. Existing TEXT values bypass this parser.

The shared KeyEventHandler.execute_template guards all five inline modes and the editor identity/start epoch. EditorReadback captures a bounded prefix/suffix, absolute selection endpoints and exact selected text. Resolution and validation happen before one commitText, including absolute-offset overflow checks; replacement readback verifies the collapsed end and surrounding text. A cursor marker then uses setSelection with a UTF-16 offset and verifies it. False/throwing/partial writes and failed cursor moves return failure without retries or compensating deletions. Batch editing would not make the operation atomic.

Only plain ClipData.Item.text is read, only when requested; content coercion/network reads are absent. Password fields fail closed. Editing invalidates old word/space/undo and prediction ownership through LearningHooks.onExplicitEdit; payload data does not enter the learning funnel. Failure feedback uses the suggestion bar. The custom-gesture path returns immediately after TEMPLATE handling, including failure, so a rejected payload cannot fall through as an ordinary named command or literal string.

Both assignment contexts use CommandPaletteDialog and its template editor. JSON, backup and XML preserve TEMPLATE. KeyValueParser supports both quoted template syntaxes; prefixed template, timestamp and intent payloads share quote parsing that preserves escaped apostrophes and backslashes. Coverage lives in KeyValueParserTest and native DynamicTemplateTest. Final run counts belong to the testing strategy source.

Verified suffix transactions and learning

append_possessive / append_apostrophe map to Editing keys and are included in the command/Extra Keys catalogs, labels and accessibility descriptions. The shared key handler guards inline priority and invokes SuggestionHandler. Custom gestures route these commands directly with success-only feedback rather than executing raw TEXT.

SuggestionHandler captures WordEditReceipt after a successful candidate commit or verified typed-word boundary: editor identity/session, Config generation/language, exact VerifiedSuffixEdit and the latest opaque LearningCommit. At invocation it revalidates collapsed editor readback and full word+owned separator.

A space the user types immediately after completing a word is recorded as that word’s attachable separator (James + typed space + Append ‘s → James's ). The command is an explicit request to edit the word just completed, and that space is the boundary that completed it; the receipt holds the caret and guard text of that exact moment. A space typed anywhere else is never attachable: any later edit, caret move or field change expires the receipt first. This does not change smart punctuation, which still preserves typed spaces for punctuation keys. VerifiedSuffixEdit requires acknowledged composition completion and unchanged readback before selecting only the owned tail, writing the exact suffix/space, and reading back. A false or throwing result is reconciled only when the desired edit is observed exactly, without retry. Suffix-only undo precedes legacy word undo and consumes an attempted failed undo.

Predictor’s typed receipt API validates instance/commit/epoch, prior context, immutable learning gates, language and store mutation versions. ContextModel aggregates receipts from actual writes; disabled/no-op sinks produce none. Replacement validates under bigram→trigram→vocabulary locks (never held across InputConnection), consumes the old identity, reverses owned increments, restores prior context and records the full new word with a fresh handle. Config/field permission/language away-and-back and store reset/import/cleanup expire handles permanently. Timestamps/cap evictions are not rewound; persistence is not an atomic storage transaction. The existing word-based rollback API remains separate; suffix replacement never uses its spelling fallback.

Editor callback limitation

Android selection callbacks contain no operation ID and arrive asynchronously; API 24–27 TextView/WebView/Compose editors and Chrome can report them several main-loop turns late (Saga, Android 14, Chrome: before the fix, the Backspace after “Append apostrophe” deleted two characters instead of undoing the suffix). Suffix writes and continuous separators accept their own expected original-caret/tail-selection/final-caret callbacks through an OwnedSelectionLedger of at most three entries that expires by consumption, by a later operation, or after a 2-second monotonic bound — not on the next loop turn. Each accepted callback also requires the same session and an exact readback of the operation’s final state. A word receipt additionally accepts the two carets just before its word (left by a separate separator or partial-deletion write in the same commit), again only on exact readback, so typing “I” then swiping “want” keeps “Append ‘s” available. Real ranged selections, unmatched or expired callbacks invalidate ownership.

Cost: the receipt is one editor readback (three InputConnection reads; the whole-word check reuses its guard text), and the final callback after each plain word reads nothing, because every command re-verifies the receipt in full. Deferring the commit-time readback was rejected: its caret is what proves the cursor never left the word. The shared selection gate keeps UI notifications unconditional, while consumed owned events bypass continuous cancellation and manual cursor synchronization; otherwise stale coordinates would erase the new auto-space stamp. An identical manual away/back sequence inside that window (up to 2 seconds) can be indistinguishable from delayed owned callbacks. This limitation requires cross-editor device testing; it is not an absolute guarantee that every cursor excursion can be identified.

Template execution finishes composition and verifies the same editor/selection before its single text write. Refusal to finish composition aborts before insertion. A partial write or rejected caret move is reported without repeating or compensating the text.