Skip to content
CleverKeys Wiki
implemented v1.4.0

Gesture System Overview

Overview

CleverKeys implements a multi-layered gesture recognition system that handles four gesture types: short swipes (directional swipes within a key for sublabels), long swipes (gestures across keys for neural word prediction), circle/rotation gestures (for double letters), and slider gestures (continuous value adjustment). The hasLeftStartingKey flag is the central decision point that routes touches to the appropriate handler.

This spec is the system-level overview. Per-gesture behavior is documented in the linked specifications under “Related Specifications”.

Key Files

FileClass/FunctionPurpose
src/main/kotlin/tribixbite/cleverkeys/Pointers.ktPointersTouch event handling, gesture pipeline routing (~1789 lines)
src/main/kotlin/tribixbite/cleverkeys/Pointers.ktPointers.Pointer (internal class)Per-pointer state including hasLeftStartingKey
src/main/kotlin/tribixbite/cleverkeys/GestureClassifier.ktGestureClassifierTAP vs SWIPE classification (65 lines)
src/main/kotlin/tribixbite/cleverkeys/Gesture.ktGestureCircle/rotation state machine (141 lines)
src/main/kotlin/tribixbite/cleverkeys/customization/SwipeDirection.ktSwipeDirection8-direction enum for short swipes
src/main/kotlin/tribixbite/cleverkeys/Config.ktGesture settingsConfiguration thresholds
src/main/kotlin/tribixbite/cleverkeys/Keyboard2View.ktgetKeyHypotenuse()Key dimension calculation

Architecture

Touch Events (Keyboard2View.onTouchEvent)


      Pointers.kt

     ┌─────┴─────┐
     │           │
  onTouchMove  onTouchUp
     │           │
     ▼           ▼
┌─────────┐  ┌──────────────────┐
│ Track   │  │ GestureClassifier │
│ hasLeft │  │    .classify()    │
│ Starting│  └────────┬─────────┘
│ Key     │           │
└─────────┘    ┌──────┴──────┐
               │             │
          SWIPE           TAP
               │             │
               ▼             ▼
        Neural Predictor  Short Gesture
        (onSwipeEnd)      Handler

Data Flow

Touch Tracking

  1. onTouchDown: Record start position, identify starting key
  2. onTouchMove: Update position, check if left starting key
  3. onTouchUp: Classify gesture, trigger appropriate handler

The hasLeftStartingKey Gatekeeper

This boolean flag is the single decision point determining gesture type:

// Pointers.kt, onTouchMove handler
if (ptr.key != null && !ptr.hasLeftStartingKey) {
    val keyHypotenuse = _handler.getKeyHypotenuse(ptr.key)
    val maxAllowedDistance = keyHypotenuse * (config.short_gesture_max_distance / 100.0f)
    val distanceFromStart = sqrt((x - ptr.downX).pow(2) + (y - ptr.downY).pow(2))

    if (distanceFromStart > maxAllowedDistance) {
        ptr.hasLeftStartingKey = true  // Permanently set for this touch
    }
}

See Pointers.kt:734-744 for the live implementation.

Configuration

KeyTypeDefaultDescription
short_gesture_min_distanceInt28Min displacement to trigger a short swipe, as % of key diagonal
short_gesture_max_distanceInt141The single short/long boundary. Max displacement-from-touch-down (% of key diagonal) a gesture may travel and still be a short swipe; beyond it the gesture is a long (neural word) swipe. Honored by both activation paths (see “Swipe Typing Activation”). The old “200 = disabled” label was never implemented in logic and has been retired — use the Enable Short Gestures toggle to disable short swipes.
tap_duration_thresholdLong150Max ms a gesture that already left the key may last and still be classified as a tap (touch-up path only)
circle_sensitivityInt(see Defaults.CIRCLE_SENSITIVITY)Sensitivity for loop/circle gestures (there is no circle_gesture_enabled boolean — older drafts listed one that never existed)
swipe_dist (→ swipe_dist_px)String/Int23Device-scaled distance with two live roles: mid-move slider/event subkey activation gate, and the absolute cap on the short-swipe minimum for wide keys

There is no swipe_speed_threshold / minimum-speed gate. (Older drafts of this spec listed one; it has never existed in code. Slow swipes are not rejected by speed — see the pause-recovery note in Swipe Typing.)

Unit safety: the two percent thresholds are typed as PercentOfKey (Units.kt, a @JvmInline value class like Compose’s Dp). Consumers must convert via .toPx(keyDiagonalPx) — comparing them against raw px displacement no longer compiles. Raw prefs.get* reads of gesture keys outside Config/settings/backup layers are rejected by GesturePrefAccessDriftTest.

Public API

GestureClassifier

class GestureClassifier(private val context: Context) {

    enum class GestureType { TAP, SWIPE }

    data class GestureData(
        val hasLeftStartingKey: Boolean,
        val totalDistance: Float,
        val timeElapsed: Long,
        val keyWidth: Float
    )

    fun classify(gesture: GestureData): GestureType {
        val minSwipeDistance = gesture.keyWidth / 2.0f

        return if (gesture.hasLeftStartingKey &&
                   (gesture.totalDistance >= minSwipeDistance ||
                    gesture.timeElapsed > maxTapDurationMs)) {
            GestureType.SWIPE
        } else {
            GestureType.TAP
        }
    }
}

Pointers Touch Handlers

class Pointers(
    private val handler: Handler,
    private val config: Config
) {
    // Called from Keyboard2View.onTouchEvent
    fun onTouchEvent(event: MotionEvent): Boolean

    // Internal handlers
    private fun onTouchDown(ptr: Pointer, x: Float, y: Float)
    private fun onTouchMove(ptr: Pointer, x: Float, y: Float)
    private fun onTouchUp(ptr: Pointer)

    // Gesture routing
    private fun handleShortGesture(ptr: Pointer, direction: SwipeDirection)
    private fun handleSwipeTyping(ptr: Pointer)
}

Implementation Details

Gesture Classification Logic

hasLeftStartingKeyDistanceTimeResult
FALSEanyanyTAP
TRUE< keyWidth/2<= tap_durationTAP
TRUE>= keyWidth/2anySWIPE
TRUEany> tap_durationSWIPE

Key Dimension Calculation

All thresholds use actual device pixels computed at runtime:

// Keyboard2View.kt
override fun getKeyHypotenuse(key: KeyboardData.Key): Float {
    val tc = themeComputed ?: return 0f

    // Find row height from layout
    var normalizedRowHeight = 0f
    for (row in keyboard.rows) {
        for (k in row.keys) {
            if (k == key) {
                normalizedRowHeight = row.height
                break
            }
        }
    }

    // Convert to actual pixels
    val keyHeightPx = normalizedRowHeight * tc.row_height
    val keyWidthPx = key.width * keyWidth

    return sqrt(keyWidthPx.pow(2) + keyHeightPx.pow(2))  // Diagonal in pixels
}

Short Swipe Direction Detection

Direction calculated from delta between start and end positions:

private fun calculateSwipeDirection(dx: Float, dy: Float): SwipeDirection {
    val angle = atan2(-dy.toDouble(), dx.toDouble())  // Negative Y because screen coords
    val degrees = Math.toDegrees(angle)

    return when {
        degrees in -22.5..22.5 -> SwipeDirection.E
        degrees in 22.5..67.5 -> SwipeDirection.NE
        degrees in 67.5..112.5 -> SwipeDirection.N
        degrees in 112.5..157.5 -> SwipeDirection.NW
        degrees > 157.5 || degrees < -157.5 -> SwipeDirection.W
        degrees in -157.5..-112.5 -> SwipeDirection.SW
        degrees in -112.5..-67.5 -> SwipeDirection.S
        degrees in -67.5..-22.5 -> SwipeDirection.SE
        else -> SwipeDirection.E
    }
}

Circle Gesture Detection

Circle gestures detected via rotation accumulation:

class Gesture {
    private var totalRotation: Float = 0f
    private var lastAngle: Float = 0f

    fun addPoint(x: Float, y: Float, centerX: Float, centerY: Float) {
        val currentAngle = atan2(y - centerY, x - centerX)
        val delta = normalizeAngle(currentAngle - lastAngle)
        totalRotation += delta
        lastAngle = currentAngle
    }

    fun isCircleComplete(): Boolean {
        return abs(totalRotation) >= 2 * PI  // Full circle (360 degrees)
    }

    fun getDirection(): CircleDirection {
        return if (totalRotation > 0) CircleDirection.CLOCKWISE
               else CircleDirection.COUNTER_CLOCKWISE
    }
}

Swipe Typing Activation — two paths, one boundary

A letter-key gesture can be promoted to a neural word swipe through two code paths. Both are gated on the same single displacement boundary (hasLeftStartingKey, i.e. short_gesture_max_distance), so they agree on where short ends and long begins.

Path A — mid-move latch (Pointers.kt:816-828). While the finger moves, points feed ImprovedSwipeGestureRecognizer. When the recognizer reports isSwipeTyping() (>= 2 distinct letter keys and swipe_min_distance of accumulated path) and the finger has crossed the boundary (ptr.hasLeftStartingKey), the pointer latches FLAG_P_SWIPE_TYPING. Touch-up then completes the word via onSwipeEnd (Pointers.kt:163-168).

// Pointers.kt onTouchMove, after path collection
if (_swipeRecognizer.isSwipeTyping() && ptr.hasLeftStartingKey) {
    ptr.flags = ptr.flags or FLAG_P_SWIPE_TYPING   // commit to a word
    stopLongPress(ptr)
}

The && ptr.hasLeftStartingKey conjunct is the fix for the overshoot bug: isSwipeTyping() alone can be satisfied at ~half a key-width when a short directional swipe overshoots into an adjacent letter, which previously committed a word mid-gesture and bypassed the short/long boundary. Gating on the boundary keeps sub-threshold overshoots out of Path A so they reach the touch-up short-gesture decision.

Path B — touch-up classifier (Pointers.kt:289-343), reached only when Path A did not latch. GestureClassifier.classify() (which also requires hasLeftStartingKey) decides TAP vs SWIPE; SWIPE on a char key calls onSwipeEnd, TAP falls to the short-gesture handler. In the short-gesture handler, a sub-boundary gesture that resolves to no exact-direction subkey but is a word candidate falls back to a neural word swipe — word candidates do not accept ±1-fuzzed corner matches (see Short Swipes → No-Subkey Fallback).

Because both paths require hasLeftStartingKey, short_gesture_max_distance is the one knob that moves the short/long boundary for the whole system. (The no-subkey fallback is the deliberate sub-boundary exception — an intent signal, not a second threshold.)

swipe_typing_enabled gates every word route: the mid-move latch, Path B’s classifier (a SWIPE classification is demoted to TAP when swipe typing is off — the gesture commits the starting key instead of silently dying in onSwipeEnd), the no-subkey fallback, and the return-trip rescue (both via the word-candidate check). See Short Swipes for the latter two.

Pointer State

The per-pointer state lives in the internal Pointers.Pointer class (Pointers.kt:1389). The conceptual shape:

data class Pointer(
    var key: KeyboardData.Key?,       // Starting key
    var downX: Float,                  // Initial touch X
    var downY: Float,                  // Initial touch Y
    var currentX: Float,               // Current X
    var currentY: Float,               // Current Y
    var downTime: Long,                // Touch start time
    var hasLeftStartingKey: Boolean,   // The gatekeeper flag
    var swipePath: MutableList<Point>, // Path for neural prediction
    var flags: Int                     // State flags (trackpoint, selection-delete, etc.)
)