Skip to content
CleverKeys Wiki
implemented v1.4.0 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

enum class ActionType(val displayName: String, val description: String) {
    TEXT("Text Input", "Insert text directly"),
    COMMAND("Command", "Execute keyboard command (copy, paste, cursor, etc.)"),
    KEY_EVENT("Key Event", "Send raw key event (advanced)"),
    INTENT("Send Intent", "Send Android Intent (advanced)"),
    TIMESTAMP("Timestamp", "Insert formatted date/time using SimpleDateFormat pattern")
}

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 18 categories:

Core Categories

CategoryExample Commands
CLIPBOARDcopy, paste, cut, paste_plain, paste_pinned_1..5
EDITINGundo, redo, select_all
CURSORcursor_left, cursor_right, home, end
NAVIGATIONpage_up, page_down, doc_home, doc_end
SELECTIONselect_all, selection_mode
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)
        }
    }
}

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() which detects terminal apps and sends Ctrl+V instead of performContextMenuAction(android.R.id.paste):

// CustomShortSwipeExecutor.kt
private fun handlePaste(inputConnection: InputConnection, editorInfo: EditorInfo?): Boolean {
    return if (TerminalUtils.isTerminalApp(editorInfo)) {
        sendKeyEventWithModifier(inputConnection, KeyEvent.KEYCODE_V,
            KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
    } else {
        inputConnection.performContextMenuAction(android.R.id.paste)
    }
}

This mirrors the same logic in KeyEventHandler.handlePaste() for the regular paste key. Without this, paste via custom short swipe does nothing in Termux because terminal apps don’t implement the Android context menu protocol.

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