Skip to content

Commit 5ce8d01

Browse files
committed
feat: add word suggestions and auto-correct (#58)
- Adds dictionary-based word suggestions while typing - Auto-corrects misspelled words when pressing space - Adds settings toggles for suggestions and auto-correct - Disables suggestions and correct for password fields - Uses wordfreq dictionary, only English in this commit
1 parent f6de3cb commit 5ce8d01

File tree

9 files changed

+100432
-4
lines changed

9 files changed

+100432
-4
lines changed

app/src/main/assets/dictionaries/en_US.json

Lines changed: 100002 additions & 0 deletions
Large diffs are not rendered by default.

app/src/main/kotlin/org/fossify/keyboard/activities/SettingsActivity.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ class SettingsActivity : SimpleActivity() {
6363
setupShowClipboardContent()
6464
setupSentencesCapitalization()
6565
setupShowNumbersRow()
66+
setupShowSuggestions()
67+
setupAutoCorrect()
6668
setupVoiceInputMethod()
6769

6870
binding.apply {
@@ -283,4 +285,24 @@ class SettingsActivity : SimpleActivity() {
283285
}
284286
}
285287
}
288+
289+
private fun setupShowSuggestions() {
290+
binding.apply {
291+
settingsShowSuggestions.isChecked = config.showSuggestions
292+
settingsShowSuggestionsHolder.setOnClickListener {
293+
settingsShowSuggestions.toggle()
294+
config.showSuggestions = settingsShowSuggestions.isChecked
295+
}
296+
}
297+
}
298+
299+
private fun setupAutoCorrect() {
300+
binding.apply {
301+
settingsAutoCorrect.isChecked = config.autoCorrectOnSpace
302+
settingsAutoCorrectHolder.setOnClickListener {
303+
settingsAutoCorrect.toggle()
304+
config.autoCorrectOnSpace = settingsAutoCorrect.isChecked
305+
}
306+
}
307+
}
286308
}

app/src/main/kotlin/org/fossify/keyboard/helpers/Config.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,12 @@ class Config(context: Context) : BaseConfig(context) {
9191
recentEmojis.add(0, emoji)
9292
recentlyUsedEmojis = recentEmojis.take(RECENT_EMOJIS_LIMIT)
9393
}
94+
95+
var showSuggestions: Boolean
96+
get() = prefs.getBoolean(SHOW_SUGGESTIONS, true)
97+
set(showSuggestions) = prefs.edit().putBoolean(SHOW_SUGGESTIONS, showSuggestions).apply()
98+
99+
var autoCorrectOnSpace: Boolean
100+
get() = prefs.getBoolean(AUTO_CORRECT_ON_SPACE, true)
101+
set(autoCorrectOnSpace) = prefs.edit().putBoolean(AUTO_CORRECT_ON_SPACE, autoCorrectOnSpace).apply()
94102
}

app/src/main/kotlin/org/fossify/keyboard/helpers/Constants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const val SHOW_NUMBERS_ROW = "show_numbers_row"
2424
const val SELECTED_LANGUAGES = "selected_languages"
2525
const val VOICE_INPUT_METHOD = "voice_input_method"
2626
const val RECENTLY_USED_EMOJIS = "recently_used_emojis"
27+
const val SHOW_SUGGESTIONS = "show_suggestions"
28+
const val AUTO_CORRECT_ON_SPACE = "auto_correct_on_space"
2729

2830
// differentiate current and pinned clips at the keyboards' Clipboard section
2931
const val ITEM_SECTION_LABEL = 0
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package org.fossify.keyboard.helpers
2+
3+
import android.content.Context
4+
import android.os.Handler
5+
import android.os.Looper
6+
import org.fossify.commons.helpers.ensureBackgroundThread
7+
import org.json.JSONArray
8+
import org.json.JSONException
9+
import java.io.IOException
10+
import java.util.Collections
11+
import kotlin.math.abs
12+
import kotlin.math.min
13+
14+
private const val DEBOUNCE_DELAY_MS = 100L
15+
private const val MAX_SUGGESTIONS = 3
16+
private const val MAX_LENGTH_DIFF = 2
17+
private const val MAX_EDIT_DISTANCE = 2
18+
private const val MIN_WORD_LENGTH = 2
19+
private const val CACHE_SIZE = 20
20+
21+
class SpellChecker(context: Context) {
22+
@Volatile
23+
private var dictionary: HashMap<String, Int>? = null
24+
private val mainHandler = Handler(Looper.getMainLooper())
25+
private var pendingRunnable: Runnable? = null
26+
@Suppress("MagicNumber")
27+
private val cache: MutableMap<String, List<String>> = Collections.synchronizedMap(
28+
object : LinkedHashMap<String, List<String>>(CACHE_SIZE, 0.75f, true) {
29+
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<String>>) = size > CACHE_SIZE
30+
}
31+
)
32+
33+
var onSuggestionsReady: ((List<String>) -> Unit)? = null
34+
35+
init {
36+
ensureBackgroundThread {
37+
dictionary = loadDictionary(context)
38+
}
39+
}
40+
41+
fun checkWord(word: String) {
42+
pendingRunnable?.let { mainHandler.removeCallbacks(it) }
43+
44+
pendingRunnable = Runnable {
45+
ensureBackgroundThread {
46+
val suggestions = findSuggestions(word)
47+
mainHandler.post {
48+
onSuggestionsReady?.invoke(suggestions)
49+
}
50+
}
51+
}
52+
53+
mainHandler.postDelayed(pendingRunnable!!, DEBOUNCE_DELAY_MS)
54+
}
55+
56+
fun clear() {
57+
pendingRunnable?.let { mainHandler.removeCallbacks(it) }
58+
mainHandler.post {
59+
onSuggestionsReady?.invoke(emptyList())
60+
}
61+
}
62+
63+
fun isValidWord(word: String): Boolean {
64+
return dictionary?.containsKey(word.lowercase()) ?: true
65+
}
66+
67+
fun destroy() {
68+
pendingRunnable?.let { mainHandler.removeCallbacks(it) }
69+
onSuggestionsReady = null
70+
}
71+
72+
private fun loadDictionary(context: Context): HashMap<String, Int> {
73+
val map = HashMap<String, Int>()
74+
try {
75+
val inputStream = context.assets.open("dictionaries/en_US.json")
76+
val jsonString = inputStream.bufferedReader().use { it.readText() }
77+
val jsonArray = JSONArray(jsonString)
78+
79+
for (i in 0 until jsonArray.length()) {
80+
val entry = jsonArray.getJSONArray(i)
81+
val word = entry.getString(0)
82+
val rank = jsonArray.length() - i
83+
map[word] = rank
84+
}
85+
} catch (_: IOException) {
86+
} catch (_: JSONException) {
87+
}
88+
return map
89+
}
90+
91+
private fun findSuggestions(input: String): List<String> {
92+
val dict = dictionary ?: return emptyList()
93+
if (input.length < MIN_WORD_LENGTH) return emptyList()
94+
95+
val inputLower = input.lowercase()
96+
97+
cache[inputLower]?.let { return it }
98+
99+
val prefixMatches = dict.keys
100+
.filter { word -> word.startsWith(inputLower) && word != inputLower }
101+
.sortedByDescending { dict[it] ?: 0 }
102+
.take(MAX_SUGGESTIONS)
103+
104+
if (prefixMatches.isNotEmpty()) {
105+
cache[inputLower] = prefixMatches
106+
return prefixMatches
107+
}
108+
109+
val firstChar = inputLower[0]
110+
val inputLen = inputLower.length
111+
112+
val levenshteinMatches = dict.keys
113+
.filter { word ->
114+
word[0] == firstChar && abs(word.length - inputLen) <= MAX_LENGTH_DIFF
115+
}
116+
.map { word ->
117+
word to levenshteinDistance(inputLower, word)
118+
}
119+
.filter { it.second <= MAX_EDIT_DISTANCE }
120+
.sortedWith(compareBy({ it.second }, { -(dict[it.first] ?: 0) }))
121+
.take(MAX_SUGGESTIONS)
122+
.map { it.first }
123+
124+
cache[inputLower] = levenshteinMatches
125+
return levenshteinMatches
126+
}
127+
128+
private fun levenshteinDistance(a: String, b: String, maxDistance: Int = MAX_EDIT_DISTANCE): Int {
129+
val m = a.length
130+
val n = b.length
131+
val dp = Array(m + 1) { IntArray(n + 1) }
132+
133+
for (i in 0..m) dp[i][0] = i
134+
for (j in 0..n) dp[0][j] = j
135+
136+
for (i in 1..m) {
137+
var rowMin = Int.MAX_VALUE
138+
for (j in 1..n) {
139+
val cost = if (a[i - 1] == b[j - 1]) 0 else 1
140+
dp[i][j] = min(
141+
min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
142+
dp[i - 1][j - 1] + cost
143+
)
144+
rowMin = min(rowMin, dp[i][j])
145+
}
146+
if (rowMin > maxDistance) return maxDistance + 1
147+
}
148+
149+
return dp[m][n]
150+
}
151+
}

0 commit comments

Comments
 (0)