Skip to content
CleverKeys Wiki
implemented v1.5.0 User guide

Short Swipes Technical Specification

Overview

Short swipe gesture detection for quick subkey access in 8 directions from any key.

Key Components

ComponentFilePurpose
PointersPointers.kt:200-400Swipe detection and direction
KeyboardDataKeyboardData.ktSubkey definitions
ConfigConfig.ktDistance thresholds
KeyValueKeyValue.ktSubkey values

Direction Calculation

// Pointers.kt:~280
fun getSwipeDirection(dx: Float, dy: Float): Int {
    val angle = atan2(-dy, dx) * 180 / PI  // 0° = East, 90° = North

    return when {
        angle >= -22.5 && angle < 22.5 -> DIRECTION_E      // East
        angle >= 22.5 && angle < 67.5 -> DIRECTION_NE     // North-East
        angle >= 67.5 && angle < 112.5 -> DIRECTION_N     // North
        angle >= 112.5 && angle < 157.5 -> DIRECTION_NW   // North-West
        angle >= 157.5 || angle < -157.5 -> DIRECTION_W   // West
        angle >= -157.5 && angle < -112.5 -> DIRECTION_SW // South-West
        angle >= -112.5 && angle < -67.5 -> DIRECTION_S   // South
        angle >= -67.5 && angle < -22.5 -> DIRECTION_SE   // South-East
        else -> DIRECTION_NONE
    }
}

Direction Constants

// Pointers.kt
const val DIRECTION_NONE = 0
const val DIRECTION_N = 1    // key1
const val DIRECTION_NE = 2   // key2
const val DIRECTION_E = 3    // key3
const val DIRECTION_SE = 4   // key4
const val DIRECTION_S = 5    // key5
const val DIRECTION_SW = 6   // key6
const val DIRECTION_W = 7    // key7
const val DIRECTION_NW = 8   // key8

Distance Thresholds

// Config.kt
object Defaults {
    const val SHORT_GESTURE_MIN_DISTANCE = 28   // % of key DIAGONAL (hypotenuse)
    const val SHORT_GESTURE_MAX_DISTANCE = 141  // % of key DIAGONAL; also the short/long boundary
}

Distances are measured from the touch-down point and compared against a percentage of the key diagonal (getKeyHypotenuse), not the width. short_gesture_max_distance is the single short/long boundary: at or below it a gesture is a short swipe, above it a long (neural word) swipe.

Threshold Logic

The live logic (Pointers.kt touch-up TAP branch) measures end displacement from the touch-down point against the key diagonal, with an absolute cap on the minimum so wide keys stay easy:

val distance = sqrt(dx * dx + dy * dy)            // dx,dy = lastX/Y - downX/Y
val keyHypotenuse = handler.getKeyHypotenuse(ptr.key)

// MIN: the easier of percentage and absolute. The absolute cap
// (swipe_dist_px * 0.8, from the legacy device-scaled "swipe_dist" pref) only
// wins on WIDE keys (>~1.3 units, e.g. backspace/shift/space) where the
// percentage of a large diagonal would demand uncomfortably long swipes.
// Ordinary letter keys always use the percentage.
val percentMin = keyHypotenuse * (config.short_gesture_min_distance / 100f)
val minDistance = min(percentMin, config.swipe_dist_px * 0.8f)

// MAX: the short/long boundary (same value that gates hasLeftStartingKey).
val maxDistance = keyHypotenuse * (config.short_gesture_max_distance / 100f)

if (distance >= minDistance && distance <= maxDistance) { /* short swipe */ }

Subkey Resolution

// Pointers.kt:~350
fun getSubkeyForDirection(key: Key, direction: Int): KeyValue? {
    return when (direction) {
        DIRECTION_N -> key.key1
        DIRECTION_NE -> key.key2
        DIRECTION_E -> key.key3
        DIRECTION_SE -> key.key4
        DIRECTION_S -> key.key5
        DIRECTION_SW -> key.key6
        DIRECTION_W -> key.key7
        DIRECTION_NW -> key.key8
        else -> null
    }
}

No-Subkey Fallback to Word Swipe

When a short swipe resolves to no accepted subkey in its direction but the gesture is a word candidate — the recognizer registered ≥2 distinct letter keys (including a key registered on the final sample, via promoteWordCandidacy()) and ≥swipe_min_distance of path — and swipe typing is enabled on a char key, the gesture is committed as a neural word swipe instead of falling through to a first-letter tap (Pointers.kt, the gestureValue == null branch).

Exact-direction vs ±1 fuzz. Subkey lookup normally scans ±1 of the 16 direction slots (getNearestKeyAtDirection) so deliberate flicks are forgiving of angle. Word candidates, however, only accept the exact-direction slot (i=0): default layouts populate corners densely (ne=digits, nw=symbols), so with the fuzz a word-shaped gesture almost always found something — a “we” swipe tilted <22.5° up reached ne="2", and a “the”-shaped gesture (end vector W) reached nw="%". A deliberate corner flick computes the corner’s own direction (e.g. 45° → dir 2 → exact ne) and still wins even when the flick crossed into the adjacent key; fuzz-only matches lose to the word. Non-candidates (single-key flicks, non-char keys such as backspace nw=delete_last_word) keep the full ±1 forgiveness.

This lets compact words swiped toward a direction with no sublabel (e.g. “we”) still produce a word, without weakening overshoot protection: an overshoot toward an assigned subkey takes the subkey branch above and never reaches this fallback. The fallback is bounded to the sub-boundary short zone (it lives inside distance <= maxDistance, where maxDistance uses the same short_gesture_max_distance that gates hasLeftStartingKey), so it never competes with clearly-long swipes — those have already committed via the mid-move latch.

Intent ordering at the boundary, for a gesture that registered ≥2 keys below the boundary:

Subkey assigned in direction?Outcome
Yes (exact direction)Short swipe → emit subkey (overshoot tolerated)
No (or ±1-fuzz only)Word candidate → neural word swipe; otherwise first-letter tap

Return-trip rescue. A word whose path returns near its start (“pop”, “lol”) ends with displacement below the short-swipe minimum and would fall through to a first-letter tap. The plain-tap fallthrough rescues it as a word when the gesture is a word candidate, lasted longer than tap_duration_threshold, and the end displacement is under half the traced path. Straight gestures (taps, overshoots, flicks) have displacement ≈ path and can never match; fast grazes fail the duration check. This applies whether or not short gestures are enabled.

Visual Feedback

Trail drawing during swipe:

// Keyboard2View.kt
fun drawSwipeTrail(canvas: Canvas, points: List<PointF>) {
    trailPaint.strokeWidth = config.trail_width
    trailPaint.color = config.trail_color

    for (i in 1 until points.size) {
        canvas.drawLine(
            points[i-1].x, points[i-1].y,
            points[i].x, points[i].y,
            trailPaint
        )
    }
}

Configuration

SettingKeyDefaultRange
Min Distanceshort_gesture_min_distance28 (% of key diagonal)10-60 (Config.kt:119)
Max Distance (short/long boundary)short_gesture_max_distance141 (% of key diagonal)50-200 (Config.kt:120)
Enable Short Swipesshort_gestures_enabledtrueboolean (Config.kt:118)

short_gesture_max_distance is the short/long boundary: displacement at or below it is a short swipe (subkey); beyond it the gesture continues as a word swipe. The old “200 = disabled” UI label was never implemented and has been retired (Config.kt:571). Both distances are stored as PercentOfKey — a value class that makes raw-pixel vs percent confusion uncompilable (Config.kt:570-571).

Calibration Activity

ShortSwipeCalibrationActivity.kt provides:

  1. Visual tutorial with animated diagram
  2. Slider controls for min distance and the short/long boundary — same %-of-key-diagonal units and ranges (10–60 / 50–200) as the Settings sliders; saving calls Config.refresh() so the engine picks the values up immediately
  3. Interactive practice area — measured displacement is converted to % through a representative key diagonal (width = narrow-side/10, height from the configured keyboard height over 4 rows) so feedback matches the engine’s interpretation; the real engine uses the touched key’s actual diagonal
  4. Real-time gesture type feedback (TAP / SHORT SWIPE / LONG SWIPE → word)