Skip to content

Commit 6c42ffb

Browse files
authored
Update close key spatial handling (#761)
1 parent 41e7a54 commit 6c42ffb

6 files changed

Lines changed: 240 additions & 22 deletions

File tree

app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,11 @@ class UrikInputMethodService :
12681268
val newCursorPositionInText = cursorPosInWord + char.length
12691269

12701270
if (isStartingNewWord) {
1271-
inputState.composingRegionStart = outputBridge.safeGetCursorPosition()
1271+
inputState.composingRegionStart = if (inputState.isKnownCursorTrustworthy()) {
1272+
inputState.lastKnownCursorPosition
1273+
} else {
1274+
outputBridge.safeGetCursorPosition()
1275+
}
12721276
}
12731277

12741278
val needsCursorRepositioning =

app/src/main/java/com/urik/keyboard/service/InputStateManager.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ class InputStateManager(
159159
!isActivelyEditing &&
160160
lastKnownCursorPosition == composingRegionStart + displayBuffer.length
161161

162+
fun isKnownCursorTrustworthy(): Boolean = lastKnownCursorPosition != -1 &&
163+
!isActivelyEditing &&
164+
pendingTypingOus.isEmpty()
165+
162166
fun getSequenceAndBuffer(): Pair<Long, String> = synchronized(processingLock) {
163167
++processingSequence to displayBuffer
164168
}

app/src/main/java/com/urik/keyboard/service/SpellCheckManager.kt

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,22 +1409,22 @@ constructor(
14091409
private fun calculateAverageKeySpacing(keyPositions: Map<Char, android.graphics.PointF>): Double {
14101410
if (keyPositions.size < 2) return 0.0
14111411

1412-
val positions = keyPositions.values
1413-
var totalDistanceSquared = 0.0
1414-
var count = 0
1412+
val posArray = keyPositions.values.toTypedArray()
1413+
var totalMinDistance = 0.0
14151414

1416-
val posArray = positions.toTypedArray()
14171415
for (i in posArray.indices) {
1418-
val end = minOf(i + 4, posArray.size)
1419-
for (j in i + 1 until end) {
1420-
val dx = posArray[i].x - posArray[j].x
1421-
val dy = posArray[i].y - posArray[j].y
1422-
totalDistanceSquared += (dx * dx + dy * dy).toDouble()
1423-
count++
1416+
var minDistSq = Double.MAX_VALUE
1417+
for (j in posArray.indices) {
1418+
if (i == j) continue
1419+
val dx = (posArray[i].x - posArray[j].x).toDouble()
1420+
val dy = (posArray[i].y - posArray[j].y).toDouble()
1421+
val distSq = dx * dx + dy * dy
1422+
if (distSq < minDistSq) minDistSq = distSq
14241423
}
1424+
if (minDistSq != Double.MAX_VALUE) totalMinDistance += kotlin.math.sqrt(minDistSq)
14251425
}
14261426

1427-
return if (count > 0) kotlin.math.sqrt(totalDistanceSquared / count) else 0.0
1427+
return totalMinDistance / posArray.size
14281428
}
14291429

14301430
private fun calculateFrequencyBoost(frequency: Int): Double {

app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardView.kt

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,16 +2008,10 @@ constructor(
20082008
}
20092009

20102010
if (hadTouchStart) {
2011-
val dx = event.x - touchStartPoint.x
2012-
val dy = event.y - touchStartPoint.y
2013-
val distance = kotlin.math.sqrt(dx * dx + dy * dy)
2014-
2015-
if (distance < 20f) {
2016-
findKeyAt(event.x, event.y)?.let { key ->
2017-
onTap(key)
2018-
performClick()
2019-
return true
2020-
}
2011+
findKeyAt(touchStartPoint.x, touchStartPoint.y)?.let { key ->
2012+
onTap(key)
2013+
performClick()
2014+
return true
20212015
}
20222016
}
20232017

app/src/test/java/com/urik/keyboard/service/InputStateManagerTest.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,47 @@ class InputStateManagerTest {
209209

210210
assertFalse(stateManager.isComposingCursorAtExpectedEnd())
211211
}
212+
213+
@Test
214+
fun `isKnownCursorTrustworthy returns true when all conditions met`() {
215+
stateManager.lastKnownCursorPosition = 10
216+
stateManager.isActivelyEditing = false
217+
218+
assertTrue(stateManager.isKnownCursorTrustworthy())
219+
}
220+
221+
@Test
222+
fun `isKnownCursorTrustworthy returns false when lastKnownCursorPosition is -1`() {
223+
stateManager.lastKnownCursorPosition = -1
224+
stateManager.isActivelyEditing = false
225+
226+
assertFalse(stateManager.isKnownCursorTrustworthy())
227+
}
228+
229+
@Test
230+
fun `isKnownCursorTrustworthy returns false when isActivelyEditing`() {
231+
stateManager.lastKnownCursorPosition = 10
232+
stateManager.isActivelyEditing = true
233+
234+
assertFalse(stateManager.isKnownCursorTrustworthy())
235+
}
236+
237+
@Test
238+
fun `isKnownCursorTrustworthy returns false when pending OUS in queue`() {
239+
stateManager.lastKnownCursorPosition = 10
240+
stateManager.isActivelyEditing = false
241+
stateManager.enqueueTypingOus(InputStateManager.ExpectedTypingOus(0, 5, 5))
242+
243+
assertFalse(stateManager.isKnownCursorTrustworthy())
244+
}
245+
246+
@Test
247+
fun `isKnownCursorTrustworthy returns false immediately after clearInternalStateOnly`() {
248+
stateManager.lastKnownCursorPosition = 10
249+
stateManager.isActivelyEditing = false
250+
251+
stateManager.clearInternalStateOnly()
252+
253+
assertFalse(stateManager.isKnownCursorTrustworthy())
254+
}
212255
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
@file:Suppress("ktlint:standard:no-wildcard-imports")
2+
3+
package com.urik.keyboard.service
4+
5+
import android.content.Context
6+
import android.content.res.AssetManager
7+
import android.graphics.PointF
8+
import com.urik.keyboard.data.WordFrequencyRepository
9+
import com.urik.keyboard.utils.CacheMemoryManager
10+
import com.urik.keyboard.utils.ManagedCache
11+
import java.io.ByteArrayInputStream
12+
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.ExperimentalCoroutinesApi
14+
import kotlinx.coroutines.flow.MutableStateFlow
15+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
16+
import kotlinx.coroutines.test.resetMain
17+
import kotlinx.coroutines.test.runTest
18+
import kotlinx.coroutines.test.setMain
19+
import org.junit.After
20+
import org.junit.Assert.assertNotNull
21+
import org.junit.Assert.assertTrue
22+
import org.junit.Before
23+
import org.junit.Test
24+
import org.junit.runner.RunWith
25+
import org.mockito.kotlin.any
26+
import org.mockito.kotlin.anyOrNull
27+
import org.mockito.kotlin.doReturn
28+
import org.mockito.kotlin.eq
29+
import org.mockito.kotlin.mock
30+
import org.mockito.kotlin.whenever
31+
import org.robolectric.RobolectricTestRunner
32+
33+
/**
34+
* Tests that spatial proximity (key layout distance) correctly influences
35+
* autocorrect rankings when key positions are loaded.
36+
*
37+
* Uses Robolectric so that [android.graphics.PointF] constructors work correctly.
38+
*/
39+
@RunWith(RobolectricTestRunner::class)
40+
@OptIn(ExperimentalCoroutinesApi::class)
41+
class SpellCheckManagerSpatialTest {
42+
private val testDispatcher = UnconfinedTestDispatcher()
43+
44+
private lateinit var context: Context
45+
private lateinit var assetManager: AssetManager
46+
private lateinit var languageManager: LanguageManager
47+
private lateinit var wordLearningEngine: WordLearningEngine
48+
private lateinit var wordFrequencyRepository: WordFrequencyRepository
49+
private lateinit var cacheMemoryManager: CacheMemoryManager
50+
private lateinit var wordNormalizer: WordNormalizer
51+
private lateinit var keyPositionsFlow: MutableStateFlow<Map<Char, PointF>>
52+
53+
private lateinit var spellCheckManager: SpellCheckManager
54+
55+
// fox=100 (low frequency), for=100000 (high frequency)
56+
// Without correct spatial scoring, "for" wins due to frequency advantage.
57+
// With correct spatial scoring, "fox" wins: z→x is one key apart, z→r is far.
58+
private val testDictionary = "fox 100\nfor 100000"
59+
60+
// Standard QWERTY key positions (100px key width, 80px key height, staggered rows).
61+
// Order matters: stored as LinkedHashMap (q...p, a...l, z...m) so that cross-row pairs
62+
// p→a and l→z appear within 4 positions of each other in iteration — this is the pattern
63+
// that exposes the sigma inflation bug in calculateAverageKeySpacing.
64+
private val qwertyPositions: Map<Char, PointF> by lazy {
65+
linkedMapOf(
66+
'q' to PointF(50f, 40f), 'w' to PointF(150f, 40f), 'e' to PointF(250f, 40f),
67+
'r' to PointF(350f, 40f), 't' to PointF(450f, 40f), 'y' to PointF(550f, 40f),
68+
'u' to PointF(650f, 40f), 'i' to PointF(750f, 40f), 'o' to PointF(850f, 40f),
69+
'p' to PointF(950f, 40f),
70+
'a' to PointF(100f, 120f), 's' to PointF(200f, 120f), 'd' to PointF(300f, 120f),
71+
'f' to PointF(400f, 120f), 'g' to PointF(500f, 120f), 'h' to PointF(600f, 120f),
72+
'j' to PointF(700f, 120f), 'k' to PointF(800f, 120f), 'l' to PointF(900f, 120f),
73+
'z' to PointF(150f, 200f), 'x' to PointF(250f, 200f), 'c' to PointF(350f, 200f),
74+
'v' to PointF(450f, 200f), 'b' to PointF(550f, 200f), 'n' to PointF(650f, 200f),
75+
'm' to PointF(750f, 200f)
76+
)
77+
}
78+
79+
@Before
80+
fun setup() {
81+
Dispatchers.setMain(testDispatcher)
82+
83+
context = mock()
84+
assetManager = mock()
85+
languageManager = mock()
86+
cacheMemoryManager = mock()
87+
wordNormalizer = mock()
88+
whenever(wordNormalizer.stripDiacritics(any())).thenAnswer { it.arguments[0] as String }
89+
whenever(wordNormalizer.canonicalizeApostrophes(any())).thenAnswer { it.arguments[0] as String }
90+
91+
wordLearningEngine = mock {
92+
onBlocking { isWordLearned(any()) } doReturn false
93+
onBlocking { areWordsLearned(any()) } doReturn emptyMap()
94+
onBlocking { getSimilarLearnedWordsWithFrequency(any(), any(), any()) } doReturn emptyList()
95+
}
96+
97+
wordFrequencyRepository = mock {
98+
onBlocking { getFrequency(any(), any()) } doReturn 0
99+
onBlocking { getFrequencies(any(), any()) } doReturn emptyMap()
100+
}
101+
102+
val suggestionCache = ManagedCache<String, List<SpellingSuggestion>>(
103+
name = "test_suggestions",
104+
maxSize = 500,
105+
onEvict = null
106+
)
107+
val dictionaryCache = ManagedCache<String, Boolean>(
108+
name = "test_dictionary",
109+
maxSize = 1000,
110+
onEvict = null
111+
)
112+
113+
whenever(
114+
cacheMemoryManager.createCache<String, List<SpellingSuggestion>>(
115+
eq("spell_suggestions"),
116+
eq(500),
117+
anyOrNull()
118+
)
119+
).thenReturn(suggestionCache)
120+
121+
whenever(
122+
cacheMemoryManager.createCache<String, Boolean>(
123+
eq("dictionary_cache"),
124+
eq(1000),
125+
anyOrNull()
126+
)
127+
).thenReturn(dictionaryCache)
128+
129+
whenever(languageManager.currentLanguage).thenReturn(MutableStateFlow("en"))
130+
whenever(languageManager.activeLanguages).thenReturn(MutableStateFlow(listOf("en")))
131+
whenever(languageManager.effectiveDictionaryLanguages).thenReturn(MutableStateFlow(listOf("en")))
132+
133+
keyPositionsFlow = MutableStateFlow(emptyMap())
134+
whenever(languageManager.keyPositions).thenReturn(keyPositionsFlow)
135+
136+
whenever(context.assets).thenReturn(assetManager)
137+
whenever(assetManager.open("dictionaries/en_symspell.txt"))
138+
.thenAnswer { ByteArrayInputStream(testDictionary.toByteArray()) }
139+
140+
spellCheckManager = SpellCheckManager(
141+
context = context,
142+
languageManager = languageManager,
143+
wordLearningEngine = wordLearningEngine,
144+
wordFrequencyRepository = wordFrequencyRepository,
145+
cacheMemoryManager = cacheMemoryManager,
146+
ioDispatcher = testDispatcher,
147+
wordNormalizer = wordNormalizer
148+
)
149+
}
150+
151+
@After
152+
fun teardown() {
153+
Dispatchers.resetMain()
154+
}
155+
156+
@Test
157+
fun `adjacent key substitution ranks above distant key substitution despite lower frequency`() = runTest {
158+
keyPositionsFlow.emit(qwertyPositions)
159+
160+
val suggestions = spellCheckManager.getSpellingSuggestionsWithConfidence("foz")
161+
162+
val foxSuggestion = suggestions.find { it.word == "fox" }
163+
val forSuggestion = suggestions.find { it.word == "for" }
164+
165+
assertNotNull("fox should appear as a suggestion for foz", foxSuggestion)
166+
assertNotNull("for should appear as a suggestion for foz", forSuggestion)
167+
assertTrue(
168+
"fox (z→x, adjacent keys) should rank above for (z→r, distant keys) despite lower frequency; " +
169+
"fox=${foxSuggestion!!.confidence}, for=${forSuggestion!!.confidence}",
170+
foxSuggestion.confidence > forSuggestion.confidence
171+
)
172+
}
173+
}

0 commit comments

Comments
 (0)