Skip to content
CleverKeys Wiki
implemented v1.5.0 User guide

Smart Punctuation Technical Specification

Overview

Smart punctuation automatically removes the space before closing punctuation so it attaches to the preceding word. As of v1.2.7 this only applies to spaces that were auto-inserted (swipe/suggestion commit) rather than manually typed. As of v1.2.8, sentence-ending punctuation (. ! ?) re-adds a trailing space to enable autocapitalization.

SAS-1 (v1.5.0) rebuilt the decision logic around a shared pure-Kotlin object, SmartAutoSpace, and added two refinements:

  • Feature A — leading-space suppression after opening punctuation: a swiped word or tapped suggestion committed right after ( [ { “ ‘ ¿ ¡ (or a straight quote in opening position) gets no leading auto-space: (" + swipe “word” → (word.
  • Feature B — position-stamped swallow verification: the “pending automatic space” state now carries the absolute cursor position expected right after the commit. At punctuation time the swallow only fires if the flag is set, the char before the cursor is actually ' ', and the cursor sits exactly at the stamp — verify-at-use, never trust-the-flag. The closer set was extended with ” ’ … ', and the v1.2.7 chaining clobber (re-added sentence-ending space flag cleared before it could survive) was fixed.

No new setting: behavior is gated by the existing smart_punctuation, auto_space_before_suggestion, and auto_space_after_suggestion prefs.

Key Components

ComponentFilePurpose
SmartAutoSpacesrc/main/kotlin/tribixbite/cleverkeys/SmartAutoSpace.ktPure decision logic: opener/closer sets, needsLeadingSpace, isSwallowEligible (runs under runPureTests)
KeyEventHandlersrc/main/kotlin/tribixbite/cleverkeys/KeyEventHandler.kt:357-431Swallow execution in sendText + invalidation on other input/backspace
PredictionContextTrackersrc/main/kotlin/tribixbite/cleverkeys/PredictionContextTracker.kt:118-168Flag + position stamp state (markAutoSpacePending / invalidateAutoSpacePending / onCursorPositionChanged)
KeyEventReceiverBridgesrc/main/kotlin/tribixbite/cleverkeys/KeyEventReceiverBridge.kt:204-227IReceiver bridge to the tracker state
SuggestionHandlersrc/main/kotlin/tribixbite/cleverkeys/SuggestionHandler.kt:641-740Suggestion-tap commit path: Feature A check + stamp capture
InputCoordinatorsrc/main/kotlin/tribixbite/cleverkeys/InputCoordinator.kt:766-841Swipe commit path: Feature A check + stamp capture; cursor-move invalidation hook at :145-154

Architecture

Commit side (swipe / suggestion tap)                Punctuation side (typed char)
────────────────────────────────────                ──────────────────────────────
SuggestionHandler / InputCoordinator                KeyEventHandler.sendText()
  needsSpaceBefore ← SmartAutoSpace                   eligible ← SmartAutoSpace
    .needsLeadingSpace(prev, beforePrev)  [A]           .isSwallowEligible(flag, stamp,
  commitText(word + trailing space)                       actualPrevChar, actualPos)  [B]
  markAutoSpacePending(preCommitPos + len)            if closer && eligible:
         │                                              deleteSurroundingText(1, 0)
         ▼                                              sentence-ender? re-add " " and
PredictionContextTracker                                re-stamp AFTER commit (chaining)
  lastSpaceWasAutoInserted + stamp        ◄───────── invalidated by: any other input,
  onCursorPositionChanged(newPos)                    backspace (incl. #110 undos),
    (≠ stamp → invalidate)                           cursor move, field switch (clearAll)

Feature A — Leading-Space Suppression

Opener classification

// SmartAutoSpace.kt:26
private val UNAMBIGUOUS_OPENERS = setOf('(', '[', '{', '“', '‘', '¿', '¡')
// SmartAutoSpace.kt:53-61
fun isOpeningPunctuation(prevChar: Char, charBeforePrev: Char?): Boolean {
    if (prevChar in UNAMBIGUOUS_OPENERS) return true
    if (prevChar == '"' || prevChar == '\'') {
        return charBeforePrev == null ||
            charBeforePrev.isWhitespace() ||
            charBeforePrev in UNAMBIGUOUS_OPENERS
    }
    return false
}

Straight quotes are disambiguated by the char BEFORE the quote: start-of-field / whitespace / another opener → opening quote (suppress the space); letter, digit, or closer → possessive or closing quote (keep the space: kids' + swipe → kids' toys).

// SmartAutoSpace.kt:69-70
fun needsLeadingSpace(prevChar: Char, charBeforePrev: Char?): Boolean =
    !prevChar.isWhitespace() && !isOpeningPunctuation(prevChar, charBeforePrev)

Call sites

Both commit paths read TWO chars before the cursor (identical logic; suggestion-tap path shown):

// SuggestionHandler.kt:644-670 (same shape at InputCoordinator.kt:769-789)
val needsSpaceBefore = if (!isSwipeAutoInsert && !config.auto_space_before_suggestion) {
    false  // User disabled leading space before tapped suggestions
} else if (syncSuppressedField) {
    // #151: never inject a leading space into URL/email/etc. fields —
    // after replacing "exa" in "https://exa" the previous char is '/',
    // and " example" would corrupt the URL.
    false
} else {
    try {
        // SAS-1: read TWO chars — straight quotes " and ' are opener vs
        // possessive/closing depending on the char before them
        val textBefore = inputConnection.getTextBeforeCursor(2, 0)
        if (textBefore != null && textBefore.isNotEmpty()) {
            val prevChar = textBefore.last()
            val charBeforePrev =
                if (textBefore.length >= 2) textBefore[textBefore.length - 2] else null
            // SAS-1: no leading auto-space after opening punctuation
            // ( [ { " ' “ ‘ ¿ ¡ — `("` + swipe "word" → `(word`, not `( word`
            SmartAutoSpace.needsLeadingSpace(prevChar, charBeforePrev)
        } else {
            false
        }
    } catch (e: Exception) {
        // If getTextBeforeCursor fails, assume we don't need space before
        false
    }
}

The #151 syncSuppressedField URL-bar branch (SuggestionHandler.kt:646-650) is untouched by SAS-1 and short-circuits before SmartAutoSpace is consulted.

Feature B — Position-Stamped Space Swallowing

State (PredictionContextTracker)

// PredictionContextTracker.kt:120
var lastSpaceWasAutoInserted: Boolean = false
// PredictionContextTracker.kt:129-130
var autoSpaceStampedPosition: Int = -1
    private set
// PredictionContextTracker.kt:138-141
fun markAutoSpacePending(expectedCursorPosition: Int) {
    lastSpaceWasAutoInserted = true
    autoSpaceStampedPosition = expectedCursorPosition
}
// PredictionContextTracker.kt:148-151
fun invalidateAutoSpacePending() {
    lastSpaceWasAutoInserted = false
    autoSpaceStampedPosition = -1
}

The absolute cursor position is read via ExtractedText and degrades to -1 when the editor doesn’t support it:

// PredictionContextTracker.kt:64-76
fun currentCursorPosition(ic: InputConnection?): Int {
    if (ic == null) return -1
    return try {
        val extracted = ic.getExtractedText(
            android.view.inputmethod.ExtractedTextRequest(), 0
        ) ?: return -1
        if (extracted.selectionStart < 0) -1
        else extracted.startOffset + extracted.selectionStart
    } catch (e: Exception) {
        -1
    }
}

Stamp capture at commit

Both commit paths capture the pre-commit position and stamp preCommit + textToInsert.length when (and only when) a trailing space was actually added; a re-commit without a fresh trailing space invalidates any stale stamp:

// SuggestionHandler.kt:723-740 (same shape at InputCoordinator.kt:821-841)
val preCommitCursorPos = if (addedTrailingSpace) {
    PredictionContextTracker.currentCursorPosition(inputConnection)
} else {
    -1
}

Log.d(TAG, "Committing text: '$textToInsert' (length=${textToInsert.length})")
inputConnection.commitText(textToInsert, 1)

if (addedTrailingSpace) {
    contextTracker.markAutoSpacePending(
        if (preCommitCursorPos >= 0) preCommitCursorPos + textToInsert.length else -1
    )
} else {
    // SAS-1: a re-commit without a fresh trailing space makes any
    // previously pending auto-space stale — invalidate it
    contextTracker.invalidateAutoSpacePending()
}

Verify-at-use eligibility

// SmartAutoSpace.kt:89-99
fun isSwallowEligible(
    autoSpacePending: Boolean,
    stampedPosition: Int,
    actualPrevChar: Char?,
    actualPosition: Int
): Boolean {
    if (!autoSpacePending) return false
    if (actualPrevChar != ' ') return false
    // Position stamp check — only enforced when both sides are known
    return stampedPosition < 0 || actualPosition < 0 || actualPosition == stampedPosition
}

Only a plain ' ' qualifies (tabs/newlines are never eaten). When either position is unknown (-1, editors without ExtractedText), eligibility degrades to the legacy v1.2.7 flag-plus-space check rather than breaking those editors.

Swallow execution (KeyEventHandler.sendText)

// KeyEventHandler.kt:367-401
val smartPuncEnabled = Config.globalConfig().smart_punctuation
val isPunctChar = isSmartPunctuationChar(char)
val isQuote = isQuoteChar(char)

if (smartPuncEnabled && (isPunctChar || isQuote)) {
    val textBefore = conn.getTextBeforeCursor(500, 0)  // Get enough context for quote counting
    val eligible = SmartAutoSpace.isSwallowEligible(
        autoSpacePending = recv.wasLastSpaceAutoInserted(),
        stampedPosition = recv.getAutoSpaceStampedPosition(),
        actualPrevChar = textBefore?.lastOrNull(),
        actualPosition = PredictionContextTracker.currentCursorPosition(conn)
    )

    if (isPunctChar && eligible) {
        // Closing punctuation: swallow the automatic space to attach to the word
        conn.deleteSurroundingText(1, 0)
        // v1.2.8: For sentence-ending punctuation, add space after so autocap triggers
        // "hello " + "." → "hello. " (enables getCursorCapsMode to detect sentence end)
        if (isSentenceEndingPunctuation(char)) {
            textToCommit = "$char "
            // SAS-1: re-mark the re-added space as auto-inserted (with a
            // fresh position stamp) AFTER the commit below — chaining.
            reMarkAutoSpaceAfterCommit = true
        }
    } else if (isQuote && eligible) {
        // Straight double quote: parity disambiguation — an odd count of
        // prior " means an unmatched opener, so this one is CLOSING.
        // (Apostrophes are handled by isSmartPunctuationChar above.)
        if (isClosingQuote(char, textBefore)) {
            // Closing quote: delete space to attach to word
            conn.deleteSurroundingText(1, 0)
        }
        // Opening quote: keep the space (do nothing)
    }
}

Chaining (fix for the v1.2.7 clobber)

In v1.2.7-v1.4.x, the sentence-ending re-added space set the flag before the unconditional clear at the end of the punctuation branch, so it never survived and word.) produced word. ). SAS-1 re-marks AFTER the commit with a fresh stamp:

// KeyEventHandler.kt:416-428
// SAS-1: capture the pre-commit cursor position for the chaining stamp
// (after the swallow deletion above, right before the commit)
val preCommitPos =
    if (reMarkAutoSpaceAfterCommit) PredictionContextTracker.currentCursorPosition(conn)
    else -1
conn.commitText(textToCommit, 1)
if (reMarkAutoSpaceAfterCommit) {
    // SAS-1: fixes the v1.2.7 clobber where the chain flag was set BEFORE the
    // unconditional clear above and thus never survived — mark AFTER commit.
    recv.markAutoSpacePending(
        if (preCommitPos >= 0) preCommitPos + textToCommit.length else -1
    )
}

Result: word.word. )word.) — one automatic-space deletion per closer.

Invalidation matrix

TriggerSite
Any other single-char input (cleared AFTER the swallow check)KeyEventHandler.kt:406
Multi-char input (macros, emoji strings)KeyEventHandler.kt:411
Backspace (plain)KeyEventHandler.kt:126
Backspace undo of swiped word (#110)KeyEventHandler.kt:538
Backspace undo of autocorrect (#110)KeyEventHandler.kt:580
Cursor movement away from the stampInputCoordinator.kt:145-154PredictionContextTracker.onCursorPositionChanged (:161-168)
Re-commit without a fresh trailing spaceSuggestionHandler.kt:739 / InputCoordinator.kt:840
Field switchPredictionContextTracker.clearAll() (:368)

The cursor-movement hook keeps the state alive when the commit’s OWN selection callback reports exactly the stamped position:

// PredictionContextTracker.kt:161-168
fun onCursorPositionChanged(newPosition: Int) {
    if (lastSpaceWasAutoInserted &&
        autoSpaceStampedPosition >= 0 &&
        newPosition != autoSpaceStampedPosition
    ) {
        invalidateAutoSpacePending()
    }
}

Key Code Patterns

Closer set

// SmartAutoSpace.kt:39-40
private val CLOSING_PUNCTUATION =
    setOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '”', '’', '…', '\'')
// KeyEventHandler.kt:441
private fun isSmartPunctuationChar(c: Char): Boolean = SmartAutoSpace.isClosingPunctuation(c)

Straight double quote " is deliberately absent from the closer set — it is parity-resolved. Straight apostrophe ' IS a closer: mid-word contractions (don’t) never reach the swallow path because the char before the cursor there is a letter, not the pending auto-space, so an apostrophe typed right after an auto-space reads as possessive/closing (kids + 'kids').

Quote detection

// KeyEventHandler.kt:462-472
private fun isClosingQuote(quote: Char, textBefore: CharSequence?): Boolean {
    if (quote != '"') return false
    if (textBefore.isNullOrEmpty()) return false

    // Count occurrences of this quote type in the text before cursor
    val quoteCount = textBefore.count { it == quote }

    // Odd count means there's an unmatched opening quote → this is closing
    // Even count (including 0) means no unmatched quote → this is opening
    return quoteCount % 2 == 1
}
// KeyEventHandler.kt:477
private fun isQuoteChar(c: Char): Boolean = c == '"'

(The pre-v1.5.0 isLikelyApostrophe helper is gone — ' moved into the closer set, and curly quotes are unambiguous.)

Sentence-ending punctuation

// KeyEventHandler.kt:444-449
private fun isSentenceEndingPunctuation(c: Char): Boolean {
    return when (c) {
        '.', '!', '?' -> true
        else -> false
    }
}

Double-space to period

// KeyEventHandler.kt:343-356
if (config.double_space_to_period && !isKeyRepeat &&
    text.length == 1 && text[0] == ' ' && lastTypedChar == ' ' &&
    (currentTime - lastTypedTimestamp) < doubleSpaceThresholdMs) {
    // Only trigger if the character before the first space was alphanumeric
    // This prevents ". ." or ", ." sequences
    val textBefore = conn.getTextBeforeCursor(2, 0)
    val charBeforeSpace = textBefore?.getOrNull(0)
    if (charBeforeSpace != null && charBeforeSpace.isLetterOrDigit()) {
        // Delete the previous space and insert ". "
        conn.deleteSurroundingText(1, 0)
        textToCommit = ". "
        // Reset tracking to prevent triple-space weirdness
        lastTypedChar = '.'
    }
}

IReceiver Interface

// KeyEventHandler.kt:978-983 (interface defaults)
// v1.2.7: Smart punctuation - track if last space was auto-inserted
fun wasLastSpaceAutoInserted(): Boolean = false
fun setLastSpaceAutoInserted(value: Boolean) {}
// SAS-1: position-stamped auto-space pending state (see
// PredictionContextTracker.markAutoSpacePending / SmartAutoSpace)
fun getAutoSpaceStampedPosition(): Int = -1
fun markAutoSpacePending(expectedCursorPosition: Int) {}

Bridge Implementation

// KeyEventReceiverBridge.kt:205-227
override fun wasLastSpaceAutoInserted(): Boolean {
    return contextTracker?.lastSpaceWasAutoInserted ?: false
}

override fun setLastSpaceAutoInserted(value: Boolean) {
    if (value) {
        contextTracker?.lastSpaceWasAutoInserted = true
    } else {
        // SAS-1: clearing the flag also drops the stale position stamp
        contextTracker?.invalidateAutoSpacePending()
    }
}

// SAS-1: position-stamped auto-space pending state
override fun getAutoSpaceStampedPosition(): Int {
    return contextTracker?.autoSpaceStampedPosition ?: -1
}

override fun markAutoSpacePending(expectedCursorPosition: Int) {
    contextTracker?.markAutoSpacePending(expectedCursorPosition)
}

Configuration

SettingConfig KeyDefaultSource
Smart Punctuationsmart_punctuationtrueConfig.kt:581 (default Config.kt:95)
Auto-space before suggestionauto_space_before_suggestiontrueConfig.kt:611 (default Config.kt:309)
Auto-space after suggestionauto_space_after_suggestiontrueConfig.kt:610 (default Config.kt:308)
Double Space Perioddouble_space_to_periodtrueConfig.kt:579 (default Config.kt:104)
Double Space Thresholddouble_space_threshold500 msConfig.kt:580 (default Config.kt:105)

SAS-1 introduces no new preference keys.

Test Coverage

SuiteFileCases
Pure JVM (runPureTests)src/test/kotlin/tribixbite/cleverkeys/SmartAutoSpaceLogicTest.kt22
Instrumented (ew-cli, Pixel7 API 34)src/androidTest/kotlin/tribixbite/cleverkeys/SmartAutoSpaceTest.kt28

The pure suite covers the opener/closer classification tables and every isSwallowEligible branch (no-flag, non-space prev char, empty field, position match/mismatch, unknown-stamp and unknown-position degradation, tab/newline rejection). The instrumented suite drives the real KeyEventHandler key path with a TestInputConnection implementing ExtractedText: opener suppression for swipe and tap commits, all closer swallows (incl. '), possessive-apostrophe keep, manual-space preservation, cursor-move/backspace/field-switch invalidation, punctuation chaining, plain-typing regression, and a #151 URL-bar control. Full-suite gate: 1371/1371 instrumented tests green (2026-07-15, run 1c06495a).

Version History

VersionChange
v1.5.0SAS-1: shared SmartAutoSpace pure logic; leading-space suppression after opening punctuation; position-stamped verify-at-use swallow eligibility; closer set extended with '; chaining clobber fixed
v1.2.8Auto-space after sentence-ending punctuation for autocap integration
v1.2.7Added auto-inserted space tracking for manual space preservation
v1.2.0Added quote handling (opening vs closing detection)
v1.1.0Initial smart punctuation implementation