Skip to content
Closed
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
106 changes: 106 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/LearnedCasing.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.latin

import android.content.SharedPreferences
import androidx.core.content.edit
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.Log
import org.json.JSONObject
import java.util.Locale

/**
* Remembers how the user writes words, learned from changing the case of a word with shift
* (see [Settings.PREF_SHIFT_RECAPITALIZES_WORD]). After the same word was changed to the same
* form [TIMES_TO_LEARN] times, it is written like this automatically (as a revertible correction).
* Only forms containing upper case letters are learned, e.g. "Du" or "HZD".
*/
object LearnedCasing {
private const val TAG = "LearnedCasing"
const val TIMES_TO_LEARN = 2
private const val MAX_ENTRIES = 300
private const val MAX_WORD_LENGTH = 48

class Entry(val form: String, val count: Int)

private var cachedJson: String? = null
private var cache: LinkedHashMap<String, Entry> = LinkedHashMap()

private fun key(word: String) = word.lowercase(Locale.ROOT)

@Synchronized
private fun read(prefs: SharedPreferences): LinkedHashMap<String, Entry> {
val json = prefs.getString(Settings.PREF_LEARNED_CASING, "") ?: ""
if (json == cachedJson) return cache
val map = LinkedHashMap<String, Entry>()
if (json.isNotEmpty()) {
try {
val o = JSONObject(json)
for (k in o.keys()) {
val a = o.optJSONArray(k) ?: continue
val form = a.optString(0, "")
val count = a.optInt(1, 0)
if (isLearnableForm(form) && key(form) == k && count > 0) map[k] = Entry(form, count)
if (map.size >= MAX_ENTRIES) break
}
} catch (t: Throwable) {
Log.w(TAG, "could not read learned casing: ${t.javaClass.simpleName}")
}
}
cachedJson = json
cache = map
return map
}

@Synchronized
private fun write(prefs: SharedPreferences, map: Map<String, Entry>) {
val o = JSONObject()
map.forEach { (k, e) -> o.put(k, org.json.JSONArray().put(e.form).put(e.count)) }
val json = o.toString()
prefs.edit { putString(Settings.PREF_LEARNED_CASING, json) }
cachedJson = json
cache = LinkedHashMap(map)
}

/** words with upper case letters, nothing else than letters, apostrophes and hyphens */
fun isLearnableForm(form: String): Boolean =
form.isNotEmpty() && form.length <= MAX_WORD_LENGTH
&& form.any { Character.isUpperCase(it) }
&& form.all { Character.isLetter(it) || it == '\'' || it == '-' }

/** The user changed the case of [form] with shift and used it */
@Synchronized
fun onRecapitalizedWordUsed(prefs: SharedPreferences, form: String) {
if (!isLearnableForm(form)) return
val map = LinkedHashMap(read(prefs))
val k = key(form)
val old = map.remove(k)
val count = if (old != null && old.form == form) old.count + 1 else 1
map[k] = Entry(form, count) // re-insert: most recent last
while (map.size > MAX_ENTRIES) map.remove(map.keys.first())
write(prefs, map)
}

/** Learned form for [word] if it differs from [word], null otherwise */
@Synchronized
fun learnedFormFor(prefs: SharedPreferences, word: String): String? {
if (word.isEmpty() || word.length > MAX_WORD_LENGTH) return null
val entry = read(prefs)[key(word)] ?: return null
if (entry.count < TIMES_TO_LEARN || entry.form == word) return null
return entry.form
}

/** All learned forms (also those not yet used automatically) */
@Synchronized
fun getAll(prefs: SharedPreferences): List<Entry> = read(prefs).values.sortedBy { it.form.lowercase(Locale.ROOT) }

@Synchronized
fun forget(prefs: SharedPreferences, word: String) {
val map = LinkedHashMap(read(prefs))
if (map.remove(key(word)) != null) write(prefs, map)
}

@Synchronized
fun clear(prefs: SharedPreferences) {
write(prefs, emptyMap())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import android.view.inputmethod.CorrectionInfo
import android.view.inputmethod.EditorInfo
import helium314.keyboard.compat.AppQuirksManager
import helium314.keyboard.event.Event
import helium314.keyboard.latin.LearnedCasing
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.event.InputTransaction
import helium314.keyboard.keyboard.Keyboard
import helium314.keyboard.keyboard.KeyboardActionListenerImpl
Expand Down Expand Up @@ -87,6 +89,8 @@ class InputLogic(
// the composing word at the time the shift key was pressed down, null if there was none
// used to tell a tap on shift from typing a letter while holding shift
private var mTypedWordOnShiftPress: String? = null
/** word as changed by [recapitalizeComposingWord] (not lower case), for [LearnedCasing] */
private var mRecapitalizedWord: String? = null

private var mDeleteCount = 0
private var mLastKeyTime = 0L
Expand Down Expand Up @@ -123,6 +127,7 @@ class InputLogic(
get() = mSuggestedWords

fun startInput(combiningSpec: String?, settingsValues: SettingsValues) {
mRecapitalizedWord = null
mEnteredText = null
mWordBeingCorrectedByCursor = null
mConnection.onStartInput()
Expand Down Expand Up @@ -1245,7 +1250,9 @@ class InputLogic(
commitCurrentAutoCorrection(settingsValues, separator, handler)
inputTransaction.setDidAutoCorrect()
} else {
commitTyped(settingsValues, StringUtils.newSingleCodePointString(codePoint))
// like an auto-correction, a learned capitalization can be reverted with backspace
if (commitTyped(settingsValues, StringUtils.newSingleCodePointString(codePoint), useLearnedCasing = true))
inputTransaction.setDidAutoCorrect()
}
} else if (TextExpanderUtils.isEnabled(mLatinIME)) {
val textBefore = mConnection.getTextBeforeCursor(50, 0)
Expand Down Expand Up @@ -1833,6 +1840,7 @@ class InputLogic(
mConnection.endBatchEdit()
// typing should continue in the same case: caps lock for an upper case word, lower case otherwise
KeyboardSwitcher.getInstance().setCapsLock(mRecapitalizeStatus.getCurrentMode() == RecapitalizeMode.ALL_UPPER)
mRecapitalizedWord = if (mRecapitalizeStatus.getCurrentMode() == RecapitalizeMode.ALL_LOWER) null else replacement
return true
}

Expand Down Expand Up @@ -2081,6 +2089,11 @@ class InputLogic(
val originallyTypedWord = mLastComposedWord.mTypedWord
val committedWord = mLastComposedWord.mCommittedWord
val committedWordString = committedWord.toString()
// the user doesn't want the learned case here: forget it
if (inputTransaction.settingsValues.mLearnCasing
&& LearnedCasing.learnedFormFor(mLatinIME.prefs(), originallyTypedWord) == committedWordString) {
LearnedCasing.forget(mLatinIME.prefs(), originallyTypedWord)
}
val cancelLength = committedWord.length
val separatorString = mLastComposedWord.mSeparatorString
val usePhantomSpace = separatorString == Constants.STRING_SPACE
Expand Down Expand Up @@ -2373,14 +2386,34 @@ class InputLogic(
}
}

fun commitTyped(settingsValues: SettingsValues, separatorString: String) {
if (!mWordComposer.isComposingWord()) return
/** @return whether the word was changed to a learned capitalization (like an auto-correction) */
fun commitTyped(settingsValues: SettingsValues, separatorString: String, useLearnedCasing: Boolean = false): Boolean {
if (!mWordComposer.isComposingWord()) return false
val typedWord = mWordComposer.getTypedWord()
var changed = false
if (typedWord.isNotEmpty()) {
val isBatchMode = mWordComposer.isBatchMode()
commitChosenWord(settingsValues, typedWord, LastComposedWord.COMMIT_TYPE_USER_TYPED_WORD, separatorString)
val learned = if (useLearnedCasing) applyLearnedCasing(settingsValues, typedWord) else typedWord
if (learned != typedWord) {
// like an auto-correction, so it can be reverted with backspace
commitChosenWord(settingsValues, learned, LastComposedWord.COMMIT_TYPE_DECIDED_WORD, separatorString)
mConnection.commitCorrection(
CorrectionInfo(mConnection.expectedSelectionEnd - learned.length, typedWord, learned)
)
changed = true
} else {
commitChosenWord(settingsValues, typedWord, LastComposedWord.COMMIT_TYPE_USER_TYPED_WORD, separatorString)
}
StatsUtils.onWordCommitUserTyped(typedWord, isBatchMode)
}
return changed
}

/** [word] in the form learned by [LearnedCasing], if enabled and the user did not just change its case */
private fun applyLearnedCasing(settingsValues: SettingsValues, word: String): String {
if (!settingsValues.mLearnCasing || settingsValues.mInputAttributes.mIsPasswordField) return word
if (isRecapitalizingComposingWord()) return word
return LearnedCasing.learnedFormFor(mLatinIME.prefs(), word) ?: word
}

private fun commitCurrentAutoCorrection(
Expand All @@ -2394,7 +2427,7 @@ class InputLogic(
}
val autoCorrectionOrNull = mWordComposer.getAutoCorrectionOrNull()
val typedWord = mWordComposer.getTypedWord()
val stringToCommit = autoCorrectionOrNull?.mWord ?: typedWord
val stringToCommit = applyLearnedCasing(settingsValues, autoCorrectionOrNull?.mWord ?: typedWord)
if (stringToCommit != null) {
val isBatchMode = mWordComposer.isBatchMode()
commitChosenWord(settingsValues, stringToCommit, LastComposedWord.COMMIT_TYPE_DECIDED_WORD, separator)
Expand Down Expand Up @@ -2486,6 +2519,11 @@ class InputLogic(
Log.d(TAG, "commitChosenWord() : $runTimeMillis ms to run performAdditionToUserHistoryDictionary()")
startTimeMillis = System.currentTimeMillis()
}
// the user changed the case of this word with shift: remember it
if (settingsValues.mLearnCasing && !settingsValues.mIncognitoModeEnabled && chosenWord == mRecapitalizedWord) {
LearnedCasing.onRecapitalizedWordUsed(mLatinIME.prefs(), chosenWord)
}
mRecapitalizedWord = null
mLastComposedWord = mWordComposer.commitWord(commitType, chosenWord, separatorString, ngramContext)
if (DebugFlags.DEBUG_ENABLED) {
val runTimeMillis = System.currentTimeMillis() - startTimeMillis
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ object Defaults {
const val PREF_TOUCHPAD_FULLSCREEN = false
const val PREF_FORCE_AUTO_CAPS = false
const val PREF_SHIFT_RECAPITALIZES_WORD = false
const val PREF_LEARN_CASING = false
const val PREF_OFFLINE_TEMP = 0.1f // Lower for faster, more deterministic proofreading
const val PREF_OFFLINE_TOP_P = 0.5f // Lower for faster token sampling
const val PREF_OFFLINE_TOP_K = 40
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ class Settings private constructor() : SharedPreferences.OnSharedPreferenceChang
const val PREF_PERSIST_TEXT_EDIT_MODE = "persist_text_edit_mode"
const val PREF_FORCE_AUTO_CAPS = "force_auto_caps"
const val PREF_SHIFT_RECAPITALIZES_WORD = "shift_recapitalizes_word"
const val PREF_LEARN_CASING = "learn_casing"
const val PREF_LEARNED_CASING = "learned_casing"
const val PREF_OFFLINE_TEMP = "offline_temp"
const val PREF_OFFLINE_TOP_P = "offline_top_p"
const val PREF_OFFLINE_TOP_K = "offline_top_k"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ open class SettingsValues(
val mTouchpadFullscreen: Boolean
val mForceAutoCaps: Boolean
val mShiftRecapitalizesWord: Boolean
val mLearnCasing: Boolean
val mDeleteSwipeEnabled: Boolean
val mDeleteSwipeWordByWord: Boolean
val mAutospaceAfterPunctuation: Boolean
Expand Down Expand Up @@ -446,6 +447,7 @@ open class SettingsValues(
mTouchpadFullscreen = prefs.getBoolean(Settings.PREF_TOUCHPAD_FULLSCREEN, Defaults.PREF_TOUCHPAD_FULLSCREEN)
mForceAutoCaps = prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS)
mShiftRecapitalizesWord = prefs.getBoolean(Settings.PREF_SHIFT_RECAPITALIZES_WORD, Defaults.PREF_SHIFT_RECAPITALIZES_WORD)
mLearnCasing = mShiftRecapitalizesWord && prefs.getBoolean(Settings.PREF_LEARN_CASING, Defaults.PREF_LEARN_CASING)
mDeleteSwipeEnabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE)
mDeleteSwipeWordByWord = prefs.getBoolean(Settings.PREF_DELETE_SWIPE_WORD_BY_WORD, Defaults.PREF_DELETE_SWIPE_WORD_BY_WORD)
mAutospaceAfterPunctuation = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, Defaults.PREF_AUTOSPACE_AFTER_PUNCTUATION)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.LearnedCasing
import helium314.keyboard.latin.R
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.dialogs.ThreeButtonAlertDialog

/** Shows the forms learned by [LearnedCasing], each can be removed */
@Composable
fun LearnedCasingPreference(setting: Setting) {
val prefs = LocalContext.current.prefs()
var showDialog by remember { mutableStateOf(false) }
var refresh by remember { mutableIntStateOf(0) }
val entries = remember(refresh, showDialog) { LearnedCasing.getAll(prefs) }
val active = entries.count { it.count >= LearnedCasing.TIMES_TO_LEARN }
Preference(
name = setting.title,
onClick = { showDialog = true },
description = stringResource(R.string.learned_casing_summary, active, entries.size - active)
)
if (showDialog) {
ThreeButtonAlertDialog(
onDismissRequest = { showDialog = false },
onConfirmed = { },
title = { Text(setting.title) },
confirmButtonText = null,
cancelButtonText = stringResource(R.string.dialog_close),
neutralButtonText = if (entries.isEmpty()) null else stringResource(R.string.learned_casing_forget_all),
onNeutral = { LearnedCasing.clear(prefs); refresh++ },
content = {
if (entries.isEmpty()) {
Text(stringResource(R.string.learned_casing_empty))
} else {
Column(Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())) {
entries.forEach { entry ->
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(Modifier.weight(1f)) {
Text(entry.form, style = MaterialTheme.typography.bodyLarge)
if (entry.count < LearnedCasing.TIMES_TO_LEARN)
Text(stringResource(R.string.learned_casing_not_yet), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = { LearnedCasing.forget(prefs, entry.form); refresh++ }) {
Icon(painterResource(R.drawable.ic_bin), stringResource(R.string.delete))
}
}
}
}
}
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.initPreview
import helium314.keyboard.settings.preferences.LearnedCasingPreference
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.preferences.SliderPreference
Expand Down Expand Up @@ -69,6 +70,8 @@ fun TextCorrectionScreen(
Settings.PREF_AUTO_CAP,
Settings.PREF_FORCE_AUTO_CAPS,
Settings.PREF_SHIFT_RECAPITALIZES_WORD,
Settings.PREF_LEARN_CASING,
Settings.PREF_LEARNED_CASING,

// Space
R.string.settings_category_space,
Expand Down Expand Up @@ -228,6 +231,14 @@ fun createCorrectionSettings(context: Context) = listOf(
) {
SwitchPreference(it, Defaults.PREF_SHIFT_RECAPITALIZES_WORD)
},
Setting(context, Settings.PREF_LEARN_CASING, R.string.learn_casing, R.string.learn_casing_summary) {
val prefs = LocalContext.current.prefs()
SwitchPreference(it, Defaults.PREF_LEARN_CASING,
enabled = prefs.getBoolean(Settings.PREF_SHIFT_RECAPITALIZES_WORD, Defaults.PREF_SHIFT_RECAPITALIZES_WORD))
},
Setting(context, Settings.PREF_LEARNED_CASING, R.string.learned_casing) {
LearnedCasingPreference(it)
},
Setting(context, Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD,
R.string.use_double_space_period, R.string.use_double_space_period_summary
) {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1734,4 +1734,11 @@ Ermöglicht lokale GGUF / llama.cpp-Inferenz für Korrekturlesen und Umschreiben
<string name="backup_api_keys_warning_confirm">API-Schlüssel einschließen</string>
<string name="gif_get_api_key">Holen Sie sich den API-Schlüssel</string>
<string name="gif_provider_disabled">Deaktiviert</string>
<string name="learn_casing">Schreibweisen merken</string>
<string name="learn_casing_summary">Wenn Du ein Wort zweimal gleich per Shift änderst (z. B. du → Du, hzd → HZD), wird es künftig automatisch so geschrieben. Die Rücktaste direkt danach macht es rückgängig und vergisst es</string>
<string name="learned_casing">Gemerkte Schreibweisen</string>
<string name="learned_casing_summary">%1$d aktiv, %2$d im Lernen</string>
<string name="learned_casing_empty">Noch nichts gemerkt.</string>
<string name="learned_casing_not_yet">Im Lernen: wird nach einem weiteren Mal automatisch verwendet</string>
<string name="learned_casing_forget_all">Alle vergessen</string>
</resources>
Loading