Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/main/java/helium314/keyboard/keyboard/KeyDetector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

package helium314.keyboard.keyboard

import helium314.keyboard.latin.heatmap.TouchAdaptation

/**
* This class handles key detection.
*/
Expand Down Expand Up @@ -110,6 +112,6 @@ open class KeyDetector {
primaryKey = key
}
}
return primaryKey
return TouchAdaptation.adaptedKey(keyboard, touchX, touchY, primaryKey) ?: primaryKey
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import helium314.keyboard.latin.common.Constants
import helium314.keyboard.latin.common.CoordinateUtils
import helium314.keyboard.latin.common.InputPointers
import helium314.keyboard.latin.define.DebugFlags
import helium314.keyboard.latin.heatmap.TouchAdaptation
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.settings.SettingsValues
import helium314.keyboard.latin.utils.Log
Expand Down Expand Up @@ -195,7 +196,10 @@ class PointerTracker private constructor(
sListener.onTextInput(key.outputText)
} else if (code != KeyCode.NOT_SPECIFIED) {
if (mKeyboard?.hasProximityCharsCorrection(code) == true) {
sListener.onCodeInput(code, x, y, isKeyRepeat)
val adjusted = mKeyboard?.let {
TouchAdaptation.adjustedTouch(it, key, code, x, y, mKeyDetector.getTouchX(0), mKeyDetector.getTouchY(0))
} ?: intArrayOf(x, y)
sListener.onCodeInput(code, adjusted[0], adjusted[1], isKeyRepeat)
} else {
sListener.onCodeInput(code, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, isKeyRepeat)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.latin.heatmap

import android.content.res.Configuration
import android.content.res.Resources
import helium314.keyboard.keyboard.Keyboard
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
import helium314.keyboard.latin.LatinIME
import helium314.keyboard.latin.common.Constants
import helium314.keyboard.latin.settings.Settings

/** [TypingHeatmap.KeyGeometry] for a [Keyboard] */
class KeyboardGeometry(private val keyboard: Keyboard, landscape: Boolean = isLandscape()) : TypingHeatmap.KeyGeometry {
override val layoutId = layoutId(keyboard, landscape)

companion object {
fun isLandscape() = Resources.getSystem().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE

/** floating and one-handed keyboards are held differently, so they get their own data */
private fun modeSuffix(): String = when {
LatinIME.getInstance()?.floatingKeyboardManager?.isFloating == true -> " (floating)"
Settings.getValues().mOneHandedModeEnabled -> " (one-handed)"
else -> ""
}

private class IdCache(val keyboard: Keyboard, val landscape: Boolean, val mode: String, val id: String)
@Volatile private var idCache: IdCache? = null

/** Id for the data of this keyboard; cached, as it is used for every touch event */
fun layoutId(keyboard: Keyboard, landscape: Boolean = isLandscape()): String {
val mode = modeSuffix()
val cache = idCache
if (cache != null && cache.keyboard === keyboard && cache.landscape == landscape && cache.mode == mode)
return cache.id
val id = keyboard.mId.mSubtype.mainLayoutName + (if (landscape) " (landscape)" else "") + mode
idCache = IdCache(keyboard, landscape, mode, id)
return id
}
}

override val source: Any get() = keyboard

override val aspectRatio: Float
get() = if (keyboard.mOccupiedWidth > 0) keyboard.mOccupiedHeight.toFloat() / keyboard.mOccupiedWidth else 0.35f

override fun keyRect(codePoint: Int): IntArray? {
val key = keyboard.getKey(codePoint)
?: keyboard.getKey(Character.toUpperCase(codePoint))
?: keyboard.getKey(Character.toTitleCase(codePoint))
?: return null
return intArrayOf(key.x, key.y, key.width, key.height)
}

override fun snapshot(): List<TypingHeatmap.KeyShape> {
val w = keyboard.mOccupiedWidth.toFloat()
val h = keyboard.mOccupiedHeight.toFloat()
if (w <= 0 || h <= 0) return emptyList()
return keyboard.sortedKeys.filter { it.width > 0 && it.height > 0 && !it.isSpacer }.map { key ->
val label = when (key.code) {
Constants.CODE_SPACE -> "␣"
Constants.CODE_ENTER -> "⏎"
KeyCode.DELETE -> "⌫"
KeyCode.SHIFT -> "⇧"
else -> key.label ?: ""
}
TypingHeatmap.KeyShape(
Character.toLowerCase(key.code), label.lowercase(),
key.x / w, key.y / h, key.width / w, key.height / h
)
}
}
}
143 changes: 143 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/heatmap/TypingAdaptation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.latin.heatmap

import helium314.keyboard.event.Event
import helium314.keyboard.keyboard.Key
import helium314.keyboard.keyboard.Keyboard
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
import helium314.keyboard.latin.LatinIME
import helium314.keyboard.latin.WordComposer
import helium314.keyboard.latin.common.StringUtils
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.settings.SettingsValues

/**
* Hooks for "Adapt to my typing", so the core classes only need single calls.
*
* [TypingRecorder] is used by InputLogic: it learns where the user touches each key, from the
* words finally committed. [TouchAdaptation] is used by KeyDetector and PointerTracker: it picks
* the key the user most likely meant near key borders. With the setting off, nothing is recorded
* and key detection is unchanged. The data is in [TypingHeatmap].
*/
class TypingRecorder(private val latinIME: LatinIME, private val wordComposer: WordComposer) {
// last letter of the composing word deleted with a single backspace press, for learning from self-corrections
private var deletedLetterIndex = -1
private var deletedLetter = 0
private var deletedLetterX = 0
private var deletedLetterY = 0

fun onStartInput(settingsValues: SettingsValues) {
deletedLetterIndex = -1
// load the data in background, so it is ready for key detection; nothing at all when off
if (settingsValues.mAdaptToTyping) TypingHeatmap.init(latinIME)
}

fun onFinishInput() = TypingHeatmap.onFinishInput()

/** Called for every event before the word composer processes it */
fun onEvent(event: Event, settingsValues: SettingsValues) {
if (event.keyCode != KeyCode.DELETE) TypingHeatmap.finalizePending()
trackLetterRetype(event, settingsValues)
}

/**
* Unless this backspace reverts the autocorrection ([onCommitReverted]), the user edits the
* committed word, so we don't know which word was finally used.
*/
fun onBackspace() = TypingHeatmap.onBackspace()

fun onCommitReverted() = TypingHeatmap.onCommitReverted()

/** A word is committed: remember where its keys were touched. Call before the word composer is reset. */
fun onWordCommitted(settingsValues: SettingsValues, finalWord: String) {
if (!isRecording(settingsValues)) return
val keyboard = alphabetKeyboard() ?: return
val pointers = wordComposer.getInputPointers()
val size = pointers.pointerSize
val geometry = KeyboardGeometry(keyboard, KeyboardGeometry.isLandscape())
TypingHeatmap.onWordCommitted(
latinIME, geometry, wordComposer.getTypedWord(), finalWord,
pointers.xCoordinates.copyOf(size), pointers.yCoordinates.copyOf(size), isAdapted(geometry)
)
}

/**
* If the user deletes the last letter and types a neighboring letter instead, the touch of the
* deleted letter was meant for the new one. See [TypingHeatmap.onLetterRetyped].
*/
private fun trackLetterRetype(event: Event, settingsValues: SettingsValues) {
val deletedIndex = deletedLetterIndex
deletedLetterIndex = -1
if (!isRecording(settingsValues)) return
if (event.keyCode == KeyCode.DELETE) {
if (event.isKeyRepeat || !wordComposer.isComposingWord() || wordComposer.isCursorFrontOrMiddleOfComposingWord()) return
val codePoints = StringUtils.toCodePointArray(wordComposer.getTypedWord())
val index = codePoints.size - 1
val pointers = wordComposer.getInputPointers()
if (index < 0 || index >= pointers.pointerSize) return
deletedLetterIndex = index
deletedLetter = codePoints[index]
deletedLetterX = pointers.xCoordinates[index]
deletedLetterY = pointers.yCoordinates[index]
return
}
if (deletedIndex < 0) return
val codePoint = event.codePoint
if (codePoint <= 0 || !Character.isLetter(codePoint) || event.x < 0 || event.y < 0) return
if (wordComposer.size() != deletedIndex) return
val keyboard = alphabetKeyboard() ?: return
val geometry = KeyboardGeometry(keyboard)
TypingHeatmap.onLetterRetyped(
latinIME, geometry, deletedLetter, deletedLetterX, deletedLetterY, codePoint, isAdapted(geometry)
)
}

// no real touch positions for gestures or words restarted from existing text
private fun isRecording(settingsValues: SettingsValues) = settingsValues.mAdaptToTyping
&& !settingsValues.mIncognitoModeEnabled && !wordComposer.isBatchMode() && !wordComposer.isResumed()

private fun alphabetKeyboard(): Keyboard? =
latinIME.keyboardSwitcher.keyboard?.takeIf { it.mId.isAlphabetKeyboard }

/** whether adaptation was already active for this layout, for comparing misses before and after */
private fun isAdapted(geometry: KeyboardGeometry) =
TypingHeatmap.getOffsets(null, geometry.layoutId, TypingHeatmap.DEFAULT_STRENGTH).isNotEmpty()
}

object TouchAdaptation {
private fun isActive(keyboard: Keyboard, code: Int): Boolean {
val settingsValues = Settings.getValues()
return settingsValues.mAdaptToTyping && !settingsValues.mInputAttributes.mIsPasswordField
&& Character.isLetter(code) && keyboard.mId.isAlphabetKeyboard
}

private fun offsets(keyboard: Keyboard) =
TypingHeatmap.getOffsets(null, KeyboardGeometry.layoutId(keyboard), TypingHeatmap.DEFAULT_STRENGTH)

/**
* Key chosen with the learned typing offsets of the user, if it differs from [primaryKey].
* Only letter keys on alphabet keyboards are affected, never in password fields.
* [touchX] and [touchY] are keyboard coordinates, like the learned data.
*/
fun adaptedKey(keyboard: Keyboard, touchX: Int, touchY: Int, primaryKey: Key?): Key? {
if (primaryKey == null || !isActive(keyboard, primaryKey.code)) return null
val offsets = offsets(keyboard)
if (offsets.isEmpty()) return null
val keys = keyboard.getNearestKeys(touchX, touchY).filter { Character.isLetter(it.code) && it.width > 0 && it.height > 0 }
val candidates = keys.map {
TypingHeatmap.Candidate(Character.toLowerCase(it.code), it.x.toFloat(), it.y.toFloat(), it.width.toFloat(), it.height.toFloat())
}
val code = TypingHeatmap.adaptedKeyCode(touchX.toFloat(), touchY.toFloat(), candidates, offsets) ?: return null
return keys.firstOrNull { Character.toLowerCase(it.code) == code }
}

/**
* Touch moved by the learned offset of the key, so the dictionary sees a typical touch.
* [x] and [y] are view coordinates, [correctionX] and [correctionY] convert them to keyboard coordinates.
*/
fun adjustedTouch(keyboard: Keyboard, key: Key, code: Int, x: Int, y: Int, correctionX: Int, correctionY: Int): IntArray {
if (!isActive(keyboard, code)) return intArrayOf(x, y)
val offset = offsets(keyboard)[Character.toLowerCase(code)] ?: return intArrayOf(x, y)
return TypingHeatmap.adjustForDictionary(x, y, correctionX, correctionY, key.width, key.height, offset)
}
}
Loading
Loading