Home / Specs / Gesture System
Core v1.0.0

Gesture System

Swipe detection, multi-touch, and gesture trails

Gesture Recognition System Specification

Status: Implemented

Last Updated: 2025-12-12


1. Overview

CleverKeys implements a multi-layered gesture recognition system that handles:

Core Files

| File | Lines | Purpose |

|------|-------|---------|

| Pointers.kt | ~1100 | Touch event handling, gesture pipeline routing |

| GestureClassifier.kt | 65 | TAP vs SWIPE classification |

| Gesture.kt | 141 | Circle/rotation state machine |

| Config.kt | — | Gesture configuration options |


2. Gesture Pipeline Architecture

Touch Events (Keyboard2View)

Pointers.kt

┌─────┴─────┐

│ │

onTouchMove onTouchUp

│ │

▼ ▼

┌─────────┐ ┌──────────────────┐

│ Track │ │ GestureClassifier │

│ hasLeft │ │ .classify() │

│ Starting│ └────────┬─────────┘

│ Key │ │

└─────────┘ ┌──────┴──────┐

│ │

SWIPE TAP

│ │

▼ ▼

Neural Predictor Short Gesture

(onSwipeEnd) Handler


3. The hasLeftStartingKey Gatekeeper

The hasLeftStartingKey boolean flag is the single decision point that determines whether a gesture becomes a long swipe (neural prediction) or remains eligible for short gesture handling.

Setting the Flag (During MOVE)

// 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)² + (y - ptr.downY)²)

if (distanceFromStart > maxAllowedDistance) {

ptr.hasLeftStartingKey = true // Permanently set for this touch

}

}

Key Dimension Calculation

All thresholds use actual device pixels computed at runtime:

// Keyboard2View.kt

override fun getKeyHypotenuse(key: KeyboardData.Key): Float {

val tc = _tc ?: return 0f // Theme.Computed with device-specific scaling

// Find row height (normalized value from layout XML)

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 // Device-specific

val keyWidthPx = key.width _keyWidth // Screen width / keys

return sqrt(keyWidthPx² + keyHeightPx²) // Diagonal in pixels

}

Where:

  • tc.row_height = computed from device screen height and user's keyboard height percentage setting
  • _keyWidth = (screenWidth - margins) / keyboard.keysWidth

4. GestureClassifier (TAP vs SWIPE)

The GestureClassifier provides unified classification on touch UP:

// GestureClassifier.kt

class GestureClassifier(private val context: Context) {

private val maxTapDurationMs: Long

get() = Config.globalConfig().tap_duration_threshold

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

}

}

}

Classification Logic

| hasLeftStartingKey | Distance | Time | Result |

|--------------------|----------|------|--------|

| FALSE | any | any | TAP |

| TRUE | < keyWidth/2 | <= tap_duration | TAP |

| TRUE | >= keyWidth/2 | any | SWIPE |

| TRUE | any | > tap_duration | SWIPE |


5. Short Gesture Detection

When classified as TAP, short gesture detection checks if the movement qualifies as a directional swipe within the key.

5.1 Dual Threshold System: SHORT_GESTURE_MIN_DISTANCE vs SWIPE_DIST

CleverKeys uses two complementary thresholds to determine the minimum distance required for a short gesture. This dual system ensures wide keys (like Backspace, Space) don't require massive swipes while small keys don't trigger accidentally.

#### The Two Threshold Types

| Setting | Type | Default | Storage | Purpose |

|---------|------|---------|---------|---------|

| short_gesture_min_distance | Relative (% of key diagonal) | 37% | Int in prefs | Scales with key size |

| swipe_distswipe_dist_px | Absolute (screen-scaled pixels) | 23 | String in prefs | Fixed pixel cap |

#### How They Work Together (Pointers.kt:287-299)

// 1. PERCENTAGE-BASED threshold: % of key's diagonal (hypotenuse)

val percentMinThreshold = keyHypotenuse (_config.short_gesture_min_distance / 100.0f)

// 2. ABSOLUTE threshold: swipe_dist converted to device-specific pixels

val absoluteThreshold = _config.swipe_dist_px.toFloat()

// 3. Relaxation factor (0.8x) for Manhattan→Euclidean conversion

// Manhattan distance is ~1.4x Euclidean for diagonals

val effectiveAbsolute = if (absoluteThreshold > 0) absoluteThreshold 0.8f else Float.MAX_VALUE

// 4. USE THE SMALLER (easier to trigger) THRESHOLD

val minDistance = min(percentMinThreshold, effectiveAbsolute)

#### Why MIN() Instead of MAX()?

Problem: Different key sizes need different thresholds.

| Key Type | Typical Width | Hypotenuse | 37% of Hypotenuse |

|----------|---------------|------------|-------------------|

| Letter (Q,W,E...) | ~90px | ~127px | ~47px |

| Backspace | ~180px | ~254px | ~94px |

| Space bar | ~400px | ~410px | ~152px |

Without the absolute threshold cap, you'd need to swipe 152 pixels on the space bar just to trigger a short gesture — far too much!

Solution: Take the minimum of:

  • 1. Percentage-based (prevents accidental triggers on small keys)
  • 2. Absolute (caps the maximum for wide keys)

#### Practical Examples

Backspace key (180px wide, ~254px diagonal):

  • • Percentage threshold: 254 × 0.37 = 94px
  • • Absolute threshold: ~70px (after DPI scaling and 0.8 factor)
  • Effective threshold: min(94, 70) = 70px ← absolute wins

Small letter key (90px wide, ~127px diagonal):

  • • Percentage threshold: 127 × 0.37 = 47px
  • • Absolute threshold: ~70px
  • Effective threshold: min(47, 70) = 47px ← percentage wins

#### swipe_dist_px Calculation (Config.kt:353-356)

The raw swipe_dist preference value (stored as string "23") is converted to device-specific pixels:

// DPI ratio accounts for non-square pixels

val dpi_ratio = maxOf(dm.xdpi, dm.ydpi) / minOf(dm.xdpi, dm.ydpi)

// Base scaling from screen dimensions

val swipe_scaling = minOf(dm.widthPixels, dm.heightPixels) / 10f dpi_ratio

// Convert preference value (0-100 scale) to pixels

val swipe_dist_value = safeGetString(_prefs, "swipe_dist", "23").toFloatOrNull() ?: 23f

swipe_dist_px = swipe_dist_value / 25f swipe_scaling

This normalization produces approximately:

  • • ~70-100px on a 1080p phone (5-6" screen)
  • • ~100-140px on a 1440p phone/tablet

#### Tuning Guidelines

| Symptom | Adjust |

|---------|--------|

| Accidental triggers on small letter keys | Increase short_gesture_min_distance (e.g., 37 → 45%) |

| Hard to trigger on small keys | Decrease short_gesture_min_distance (e.g., 37 → 30%) |

| Accidental triggers on wide keys (Space, Backspace) | Increase swipe_dist (e.g., 23 → 30) |

| Hard to trigger on wide keys | Decrease swipe_dist (e.g., 23 → 18) |

#### Related Settings

| Setting | Default | Effect |

|---------|---------|--------|

| short_gesture_max_distance | 141% | Maximum travel before becoming a long swipe (exits key boundary) |

| short_gestures_enabled | true | Master toggle for short gesture detection |

5.2 Short Gesture Detection Flow

// Pointers.kt, onTouchUp handler

if (_config.short_gestures_enabled && !ptr.hasLeftStartingKey) {

val dx = ptr.lastX - ptr.downX

val dy = ptr.lastY - ptr.downY

val distance = sqrt(dx dx + dy dy)

// Calculate dual threshold (see Section 5.1)

val keyHypotenuse = _handler.getKeyHypotenuse(ptr.key)

val percentMinThreshold = keyHypotenuse (_config.short_gesture_min_distance / 100.0f)

val absoluteThreshold = _config.swipe_dist_px.toFloat()

val effectiveAbsolute = if (absoluteThreshold > 0) absoluteThreshold 0.8f else Float.MAX_VALUE

val minDistance = min(percentMinThreshold, effectiveAbsolute)

if (distance >= minDistance) {

// Calculate 16-direction (0-15)

val angle = atan2(dy, dx) + Math.PI

val direction = ((angle 8 / Math.PI).toInt() + 12) % 16

// Map to 8-direction for sublabel lookup

val gestureValue = getNearestKeyAtDirection(ptr, direction)

if (gestureValue != null) {

_handler.onPointerDown(gestureValue, false)

_handler.onPointerUp(gestureValue, ptr.modifiers)

return // Exit - gesture handled

}

}

}

// Fall through to regular TAP handling

Direction Mapping

16-direction to key position mapping:

| Direction | Angle Range | Key Position |

|-----------|-------------|--------------|

| 0 | 348.75° - 11.25° | East (E) |

| 2 | 33.75° - 56.25° | Southeast (SE) |

| 4 | 78.75° - 101.25° | South (S) |

| 6 | 123.75° - 146.25° | Southwest (SW) |

| 8 | 168.75° - 191.25° | West (W) |

| 10 | 213.75° - 236.25° | Northwest (NW) |

| 12 | 258.75° - 281.25° | North (N) |

| 14 | 303.75° - 326.25° | Northeast (NE) |


6. Circle/Rotation Gesture State Machine

The Gesture.kt class implements a state machine for detecting rotation patterns, primarily used for Slider activation:

States

enum class State {

Cancelled, // Gesture was cancelled (rotation reversed)

Swiped, // Initial swipe, no rotation detected yet

Rotating_clockwise, // Clockwise rotation in progress

Rotating_anticlockwise, // Counter-clockwise rotation in progress

Ended_swipe, // Simple swipe completed

Ended_center, // Roundtrip (swipe out and back)

Ended_clockwise, // Clockwise circle completed

Ended_anticlockwise // Counter-clockwise circle completed

}

enum class Name {

None, // Cancelled

Swipe, // Simple directional swipe

Roundtrip, // Swipe out and return to center

Circle, // Clockwise rotation

Anticircle // Counter-clockwise rotation

}

Direction Difference Algorithm

// Find shortest path between two directions on 16-point circle

fun dirDiff(d1: Int, d2: Int): Int {

val n = 16

if (d1 == d2) return 0

val left = (d1 - d2 + n) % n

val right = (d2 - d1 + n) % n

return if (left < right) -left else right

}

Key insight: Uses modular arithmetic for circular distance:

State Transitions

Touch Down (direction D)

[Swiped, dir=D]

Direction change detected?

(|dirDiff| >= circle_sensitivity)

┌────┴────┐

NO YES

│ │

│ ┌────┴────┐

│ CW CCW

│ │ │

│ ▼ ▼

│ [Rotating [Rotating

│ _clockwise] _anticlockwise]

│ │ │

│ └────┬────┘

│ │

│ Rotation reversed?

│ ┌────┴────┐

│ YES NO

│ │ │

│ ▼ │

│ [Cancelled] │

│ │

└──────┬──────┘

Touch Up

Return to center?

┌──────┴──────┐

YES NO

│ │

▼ ▼

[Ended_center] [Ended_* based on state]

│ │

▼ ▼

Roundtrip Swipe/Circle/Anticircle


7. Configuration Options

Config.kt Settings

| Setting | Type | Default | Range | Description |

|---------|------|---------|-------|-------------|

| short_gestures_enabled | Boolean | true | — | Enable short gesture detection |

| short_gesture_min_distance | Int | 37 | 10-95% | Minimum travel to trigger (% of key diagonal) |

| short_gesture_max_distance | Int | 141 | 50-200% | Maximum travel before becoming long swipe |

| swipe_dist | String | "23" | 0-100 | Absolute threshold base (see Section 5.1) |

| swipe_dist_px | Float | varies | — | Computed from swipe_dist + screen DPI |

| tap_duration_threshold | Long | 150 | 50-500ms | Maximum duration for TAP classification |

| swipe_typing_enabled | Boolean | true | — | Enable neural swipe prediction |

| circle_sensitivity | Int | 2 | 1-8 | Minimum direction change for rotation detection |

Threshold Behavior

| max_distance | Effect |

|--------------|--------|

| 50% | Very strict - must stay within half key diagonal |

| 100% | Standard - can travel one full key diagonal |

| 150% | Lenient - 1.5× key diagonal allowed |

| 200% | Effectively disabled - very permissive |


8. Pipeline Mutual Exclusivity

The gesture pipelines are mutually exclusive by design:

hasLeftStartingKey = TRUE

→ GestureClassifier returns SWIPE (if conditions met)

→ onSwipeEnd() called

→ return (exit early)

→ Short gesture code NEVER executes

hasLeftStartingKey = FALSE

→ GestureClassifier returns TAP

→ Short gesture check runs

→ Requires !ptr.hasLeftStartingKey (satisfied)

→ Short gesture may trigger OR fall through to regular TAP

Guarantee: Both pipelines cannot trigger for the same touch event because hasLeftStartingKey is a single boolean that gates both paths.


9. Integration Points

Keyboard2View.kt

KeyEventHandler.kt

IPointerEventHandler Interface

interface IPointerEventHandler {

fun onPointerDown(value: KeyValue, isSwipe: Boolean)

fun onPointerUp(value: KeyValue, mods: Modifiers)

fun onSwipeMove(x: Float, y: Float, recognizer: SwipeRecognizer)

fun onSwipeEnd(recognizer: SwipeRecognizer)

fun getKeyHypotenuse(key: KeyboardData.Key): Float

fun getKeyWidth(key: KeyboardData.Key): Float

// ...

}


10. Performance Characteristics

| Operation | Complexity | Latency |

|-----------|------------|---------|

| Direction calculation | O(1) | < 0.1ms |

| GestureClassifier.classify() | O(1) | < 0.1ms |

| hasLeftStartingKey check | O(1) | < 0.1ms |

| Short gesture direction lookup | O(n) | < 1ms |

| State machine transition | O(1) | < 0.1ms |

All operations avoid heap allocations in the hot path.


11. Related Specifications