diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 7d53c6707..1edd12c45 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -237,6 +237,16 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp return } KeyCode.TOGGLE_AUTOCORRECT -> return settings.toggleAutoCorrect() + KeyCode.TOGGLE_AUTO_CAP, KeyCode.TOGGLE_FORCE_AUTO_CAPS -> { + if (primaryCode == KeyCode.TOGGLE_AUTO_CAP) settings.toggleAutoCapitalization() + else settings.toggleForceAutoCapitalization() + // An automatic shift refresh must not consume an explicit one-shot Shift. + if (keyboardSwitcher.keyboard?.mId?.isAlphabetShiftedManually != true) { + keyboardSwitcher.requestUpdatingShiftState(latinIME.currentAutoCapsState, latinIME.currentRecapitalizeState) + } + keyboardSwitcher.suggestionStripView?.updateToolbarButtonsActivatedState() + return + } KeyCode.TOGGLE_INCOGNITO_MODE -> { settings.toggleAlwaysIncognitoMode() // Invalidate keyboard to update spacebar incognito icon immediately diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index 9613135af..a7ed92147 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -165,6 +165,8 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect + ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap + ToolbarKey.FORCE_AUTO_CAPS -> R.drawable.ic_force_auto_caps ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close ToolbarKey.HANDWRITING -> R.drawable.ic_edit @@ -245,6 +247,8 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect + ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap + ToolbarKey.FORCE_AUTO_CAPS -> R.drawable.ic_force_auto_caps ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close ToolbarKey.HANDWRITING -> R.drawable.ic_edit @@ -325,6 +329,8 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad_rounded ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect_rounded + ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap + ToolbarKey.FORCE_AUTO_CAPS -> R.drawable.ic_force_auto_caps ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close_rounded ToolbarKey.HANDWRITING -> R.drawable.ic_edit diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index 056c083a3..a11e2033e 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -205,6 +205,8 @@ object KeyCode { const val CLEAR_HANDWRITING = -10075 const val SWITCH_TO_USER_IME = -10076 const val OCR = -10077 + const val TOGGLE_AUTO_CAP = -10079 + const val TOGGLE_FORCE_AUTO_CAPS = -10080 // Intents @@ -231,7 +233,7 @@ object KeyCode { SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING, OCR, - CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWITCH_TO_USER_IME + CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWITCH_TO_USER_IME, TOGGLE_AUTO_CAP, TOGGLE_FORCE_AUTO_CAPS -> this // conversion diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt index c9f00f4af..fa92de8d9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt @@ -131,6 +131,18 @@ class Settings private constructor() : SharedPreferences.OnSharedPreferenceChang prefs.edit().putBoolean(PREF_AUTO_CORRECTION, !oldValue).apply() } + fun toggleAutoCapitalization() { + val prefs = mPrefs ?: return + val oldValue = prefs.getBoolean(PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) + prefs.edit().putBoolean(PREF_AUTO_CAP, !oldValue).apply() + } + + fun toggleForceAutoCapitalization() { + val prefs = mPrefs ?: return + val oldValue = prefs.getBoolean(PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS) + prefs.edit().putBoolean(PREF_FORCE_AUTO_CAPS, !oldValue).apply() + } + fun toggleAlwaysIncognitoMode() { val prefs = mPrefs ?: return val oldValue = prefs.getBoolean(PREF_ALWAYS_INCOGNITO_MODE, Defaults.PREF_ALWAYS_INCOGNITO_MODE) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 451e78caf..2e71f453b 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -14,6 +14,7 @@ import android.os.Handler import android.os.Looper import android.annotation.SuppressLint import androidx.core.view.forEach +import androidx.core.view.ViewCompat import helium314.keyboard.keyboard.internal.KeyboardIconsSet import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.BuildConfig @@ -179,6 +180,8 @@ class TagDrawable(private val text: String) : Drawable() { fun setToolbarButtonsActivatedStateOnPrefChange(buttonsGroup: ViewGroup, key: String?) { // settings need to be updated when buttons change if (key != Settings.PREF_AUTO_CORRECTION + && key != Settings.PREF_AUTO_CAP + && key != Settings.PREF_FORCE_AUTO_CAPS && key != Settings.PREF_ALWAYS_INCOGNITO_MODE && key?.startsWith(Settings.PREF_ONE_HANDED_MODE_PREFIX) == false) return @@ -202,12 +205,26 @@ fun setToolbarButtonActivatedState(button: ImageButton) { ONE_HANDED -> Settings.getValues().mOneHandedModeEnabled SPLIT -> Settings.getValues().mIsSplitKeyboardEnabled AUTOCORRECT -> Settings.getValues().mAutoCorrectionEnabledPerUserSettings + AUTO_CAP -> Settings.getValues().mAutoCap + FORCE_AUTO_CAPS -> Settings.getValues().mAutoCap && Settings.getValues().mForceAutoCaps SELECT_MODE -> helium314.keyboard.keyboard.KeyboardActionListenerImpl.sPersistentSelectionModeActive else -> true } button.isActivated = activated + if (button.tag == AUTO_CAP || button.tag == FORCE_AUTO_CAPS) { + val prefs = button.context.prefs() + val saved = if (button.tag == AUTO_CAP) prefs.getBoolean(Settings.PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) + else prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS) + val state = when { + !saved -> R.string.caps_control_off + activated -> R.string.caps_control_on + !prefs.getBoolean(Settings.PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) -> R.string.caps_control_paused_auto_cap + else -> R.string.caps_control_no_uppercase + } + ViewCompat.setStateDescription(button, button.context.getString(state)) + } val colors = Settings.getValues().mColors - if (activated && button.tag in listOf(INCOGNITO, ONE_HANDED, SPLIT, AUTOCORRECT, SELECT_MODE)) { + if (activated && button.tag in listOf(INCOGNITO, ONE_HANDED, SPLIT, AUTOCORRECT, AUTO_CAP, FORCE_AUTO_CAPS, SELECT_MODE)) { colors.setColor(button.background, ColorType.TOOL_BAR_KEY_ENABLED_BACKGROUND) if (button.drawable != null) { button.clearColorFilter() @@ -243,6 +260,8 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool TOUCHPAD -> KeyCode.TOGGLE_TOUCHPAD_MODE TEXT_EDIT -> KeyCode.TOGGLE_TEXT_EDIT_MODE AUTOCORRECT -> KeyCode.TOGGLE_AUTOCORRECT + AUTO_CAP -> KeyCode.TOGGLE_AUTO_CAP + FORCE_AUTO_CAPS -> KeyCode.TOGGLE_FORCE_AUTO_CAPS CLEAR_CLIPBOARD -> KeyCode.CLIPBOARD_CLEAR_HISTORY CLOSE_HISTORY -> KeyCode.ALPHA EMOJI -> KeyCode.EMOJI @@ -299,7 +318,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC // names need to be aligned with resources strings (using lowercase of key.name) enum class ToolbarKey { VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, HANDWRITING, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, - INCOGNITO, TOUCHPAD, TEXT_EDIT, AUTOCORRECT, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, + INCOGNITO, TOUCHPAD, TEXT_EDIT, AUTOCORRECT, AUTO_CAP, FORCE_AUTO_CAPS, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, PROOFREAD, TRANSLATE, OCR, SELECT_MODE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10 diff --git a/app/src/main/res/drawable/ic_auto_cap.xml b/app/src/main/res/drawable/ic_auto_cap.xml new file mode 100644 index 000000000..683fe26c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_cap.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_force_auto_caps.xml b/app/src/main/res/drawable/ic_force_auto_caps.xml new file mode 100644 index 000000000..97b1fde76 --- /dev/null +++ b/app/src/main/res/drawable/ic_force_auto_caps.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0891d1399..b2df3e71d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1555,6 +1555,11 @@ New dictionary: Touchpad mode Touchpad sensitivity Force auto-capitalization + @string/force_auto_caps_title + On + Off + Saved on; requires Auto-capitalization + Saved on; this language has no uppercase letters Force sentence capitalization on all text fields except passwords Variable toolbar direction diff --git a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt index 5af7ad7a4..679b58514 100644 --- a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt +++ b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt @@ -3,6 +3,7 @@ package helium314.keyboard import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodSubtype +import androidx.core.content.edit import com.android.inputmethod.keyboard.ProximityInfo import helium314.keyboard.keyboard.Key import helium314.keyboard.keyboard.Key.KeyParams @@ -19,16 +20,22 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.POPUP_KEYS_NORMAL import helium314.keyboard.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.LatinIME +import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.RichInputMethodSubtype import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.LayoutType import helium314.keyboard.latin.utils.LayoutType.Companion.toExtraValue import helium314.keyboard.latin.utils.LayoutUtilsCustom import helium314.keyboard.latin.utils.POPUP_KEYS_LAYOUT import helium314.keyboard.latin.utils.SubtypeUtilsAdditional +import helium314.keyboard.latin.utils.clearCustomToolbarKeyCodes +import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements @@ -51,6 +58,9 @@ class ParserTest { private lateinit var params: KeyboardParams @BeforeTest fun setUp() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions( + "${BuildConfig.APPLICATION_ID}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + ) latinIME = Robolectric.setupService(LatinIME::class.java) ShadowLog.setupLogging() ShadowLog.stream = System.out @@ -154,6 +164,50 @@ f""", // no newline at the end assertIsExpected("""[[{ "label": "a" }]]""", Expected('a'.code, "a")) } + @Test @Config(sdk = [32, 35]) + fun capsControlsHaveKeywordNumericAndPopupActions() { + for ((label, code) in listOf("auto_cap" to -10079, "force_auto_caps" to -10080)) { + assertEquals(1, KeyCode::class.java.fields.count { + it.type == Int::class.javaPrimitiveType && it.getInt(null) == code + }, "$label must have a unique keycode") + assertIsExpected("""[[{"label":"$label"}]]""", Expected(code, icon = label)) + assertIsExpected("""[[{"label":"caps","code":$code}]]""", Expected(code, "caps")) + assertIsExpected("""[[{"label":"x","popup":{"main":{"label":"$label"}}}]]""", + Expected('x'.code, "x", popups = listOf(null to code))) + val keyword = LayoutParser.parseJsonString("""[[{"label":"$label"}]]""") + .single().single().compute(params)!!.toKeyParams(params) + assertEquals(null, keyword.outputText) + val popup = LayoutParser.parseJsonString("""[[{"label":"x","popup":{"main":{"label":"$label"}}}]]""") + .single().single().compute(params)!!.toKeyParams(params).mPopupKeys!!.single() + assertEquals(label, popup.mIconName) + assertEquals(null, popup.mOutputText) + } + assertIsExpected("""[[{"label":"force_auto_cap"}]]""", + Expected(KeyCode.MULTIPLE_CODE_POINTS, "force_auto_cap", text = "force_auto_cap")) + } + + @Test @Config(sdk = [32, 35]) + fun capsControlKeywordsRespectToolbarRemapsButNumericCodesStayFixed() { + val prefs = latinIME.prefs() + val original = prefs.getString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, null) + try { + prefs.edit { putString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, "AUTO_CAP,-7,null;FORCE_AUTO_CAPS,-8,null") } + clearCustomToolbarKeyCodes() + for ((label, code, remapped) in listOf( + Triple("auto_cap", -10079, KeyCode.DELETE), + Triple("force_auto_caps", -10080, KeyCode.DELETE_WORD) + )) { + assertIsExpected("""[[{"label":"$label"}]]""", Expected(remapped, icon = label)) + assertIsExpected("""[[{"label":"caps","code":$code}]]""", Expected(code, "caps")) + assertIsExpected("""[[{"label":"x","popup":{"main":{"label":"$label"}}}]]""", + Expected('x'.code, "x", popups = listOf(null to remapped))) + } + } finally { + prefs.edit { putString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, original) } + clearCustomToolbarKeyCodes() + } + } + @Test fun labelAndExplicitCode() { assertIsExpected("""[[{ "$": "text_key" "label": "a", "code": 98 }]]""", Expected('b'.code, "a")) } diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index d9b5b2324..a099cc7b8 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -6,15 +6,27 @@ import android.os.Bundle import android.os.Handler import android.os.Message import android.text.InputType +import android.text.TextUtils import android.view.KeyEvent import android.view.inputmethod.* import androidx.core.content.edit +import androidx.core.view.ViewCompat import org.junit.Ignore import helium314.keyboard.ShadowInputMethodManager2 import helium314.keyboard.ShadowLocaleManagerCompat +import helium314.keyboard.ShadowProximityInfo +import helium314.keyboard.compat.AppQuirk +import helium314.keyboard.compat.AppQuirksManager import helium314.keyboard.event.Event import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.keyboard.Keyboard +import helium314.keyboard.keyboard.KeyboardId +import helium314.keyboard.keyboard.KeyboardLayoutSet +import helium314.keyboard.keyboard.KeyboardTheme import helium314.keyboard.keyboard.MainKeyboardView +import helium314.keyboard.keyboard.internal.KeyboardIconsSet +import helium314.keyboard.keyboard.internal.KeyboardParams +import helium314.keyboard.keyboard.internal.KeyboardState import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.ShadowFacilitator2.Companion.addedWords import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastAddedWord @@ -22,20 +34,31 @@ import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastNgramContext import helium314.keyboard.latin.ShadowFacilitator2.Companion.ngramContexts import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.common.Constants.Separators import helium314.keyboard.latin.common.LocaleUtils.constructLocale import helium314.keyboard.latin.common.StringUtils import helium314.keyboard.latin.dictionary.Dictionary import helium314.keyboard.latin.inputlogic.InputLogic import helium314.keyboard.latin.inputlogic.SpaceState import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.suggestions.SuggestionStripView import helium314.keyboard.latin.utils.ScriptUtils import helium314.keyboard.latin.utils.SubtypeSettings +import helium314.keyboard.latin.utils.ToolbarKey +import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.getEnabledToolbarKeys +import helium314.keyboard.latin.utils.getPinnedToolbarKeys +import helium314.keyboard.latin.utils.getEnabledClipboardToolbarKeys +import helium314.keyboard.latin.utils.setToolbarButtonActivatedState +import helium314.keyboard.latin.utils.upgradeToolbarPrefs import helium314.keyboard.latin.utils.getTimestampFormatter import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith import org.mockito.Mockito import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements @@ -46,6 +69,9 @@ import kotlin.streams.asSequence import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @Config(shadows = [ @@ -72,6 +98,9 @@ class InputLogicTest { @BeforeTest fun setUp() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions( + "${BuildConfig.APPLICATION_ID}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + ) latinIME = Robolectric.setupService(LatinIME::class.java) // start logging only after latinIME is created, avoids showing the stack traces if library is not found ShadowLog.setupLogging() @@ -89,6 +118,247 @@ class InputLogicTest { assertEquals("", composingText) } + @Test @Config(sdk = [32, 35]) + fun capsActionsToggleOnlyExistingPreferencesAndRefreshAutoShift() { + reset() + val state = Mockito.mock(KeyboardState::class.java) + val stateField = KeyboardSwitcher::class.java.getDeclaredField("mState").apply { isAccessible = true } + val originalState = stateField.get(latinIME.keyboardSwitcher) + stateField.set(latinIME.keyboardSwitcher, state) + try { + for ((code, pref, initial) in listOf( + Triple(-10079, Settings.PREF_AUTO_CAP, true), + Triple(-10080, Settings.PREF_FORCE_AUTO_CAPS, false) + )) { + val before = latinIME.prefs().all.toMap() + repeat(2) { pass -> + Mockito.clearInvocations(state) + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + val enabled = if (pass == 0) !initial else initial + assertEquals(before + (pref to enabled), latinIME.prefs().all) + assertEquals(if (pref == Settings.PREF_AUTO_CAP) enabled else true, settingsValues.mAutoCap) + assertEquals(if (pref == Settings.PREF_FORCE_AUTO_CAPS) enabled else false, settingsValues.mForceAutoCaps) + val caps = if (settingsValues.mAutoCap) TextUtils.CAP_MODE_SENTENCES else 0 + assertEquals(caps, inputLogic.getCurrentAutoCapsState(settingsValues)) + Mockito.verify(state).onUpdateShiftState(caps, null) + assertEquals("", text) + } + } + } finally { + stateField.set(latinIME.keyboardSwitcher, originalState) + } + } + + @Test @Config(sdk = [32, 35], shadows = [ShadowProximityInfo::class]) + fun capsActionsPreserveManualShiftAndCapsLock() { + reset() + val switcher = latinIME.keyboardSwitcher + val stateField = KeyboardSwitcher::class.java.getDeclaredField("mState").apply { isAccessible = true } + val viewField = KeyboardSwitcher::class.java.getDeclaredField("mKeyboardView").apply { isAccessible = true } + val originalState = stateField.get(switcher) + val originalView = viewField.get(switcher) + val state = Mockito.mock(KeyboardState::class.java) + val view = Mockito.mock(MainKeyboardView::class.java) + stateField.set(switcher, state) + viewField.set(switcher, view) + try { + for (element in listOf( + KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED, + KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED, + KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED + )) { + val keyboard = Keyboard(KeyboardParams().apply { + mId = KeyboardLayoutSet.getFakeKeyboardId(element) + GRID_WIDTH = 1 + GRID_HEIGHT = 1 + }) + Mockito.`when`(view.keyboard).thenReturn(keyboard) + for (code in listOf(-10079, -10080)) { + val before = latinIME.prefs().all.toMap() + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + assertTrue(before != latinIME.prefs().all) + assertEquals(keyboard, switcher.keyboard) + } + } + Mockito.verifyNoInteractions(state) + } finally { + stateField.set(switcher, originalState) + viewField.set(switcher, originalView) + } + } + + @Test @Config(sdk = [32, 35]) + fun capsActionsKeepCompositionSelectionAndPendingCorrectionUntouched() { + reset() + setText("keep ") + typeNoAssert("raw") + val correction = SuggestedWordInfo("RAW", "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + composer.setAutoCorrection(correction) + val before = Triple(text, cursor, composingText) + val wordsBefore = addedWords.toList() + for (code in listOf(-10079, -10080)) { + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + assertEquals(before, Triple(text, cursor, composingText)) + assertEquals(correction, composer.getAutoCorrectionOrNull()) + assertEquals(wordsBefore, addedWords) + checkConnectionConsistency() + } + setText("keep selected text") + setCursorPosition(5, 13) + for (code in listOf(-10079, -10080)) { + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + assertEquals("keep selected text", text) + assertEquals(5 to 13, selectionStart to selectionEnd) + assertEquals("selected", selectedText) + checkConnectionConsistency() + } + } + + @Test @Config(sdk = [32, 35]) + fun capsActionsStoreChoicesWithoutChangingAppAutocorrectOverrides() { + reset() + val packageName = "test.caps.controls" + val editorInfo = EditorInfo().apply { + inputType = currentInputType + this.packageName = packageName + } + editorInfoOverride = editorInfo + AppQuirksManager.saveQuirk(AppQuirk(packageName, autoCorrectionMode = AppQuirksManager.AUTOCORRECT_FORCE_ENABLE)) + try { + latinIME.onStartInputInternal(editorInfo, false) + latinIME.onStartInputViewInternal(editorInfo, false) + assertTrue(settingsValues.mAutoCorrectEnabled) + val before = latinIME.prefs().all.toMap() + latinIME.keyboardActionListener.onCodeInput(-10079, 0, 0, false) + latinIME.keyboardActionListener.onCodeInput(-10080, 0, 0, false) + assertEquals(before + mapOf(Settings.PREF_AUTO_CAP to false, Settings.PREF_FORCE_AUTO_CAPS to true), latinIME.prefs().all) + assertFalse(settingsValues.mAutoCap) + assertTrue(settingsValues.mForceAutoCaps) + assertTrue(settingsValues.mAutoCorrectEnabled) + assertEquals(0, inputLogic.getCurrentAutoCapsState(settingsValues)) + } finally { + AppQuirksManager.removeQuirk(packageName) + editorInfoOverride = null + } + } + + @Test @Config(sdk = [32, 35]) + fun capsToolbarAndPinnedControlsShareEffectiveStateAndDispatch() { + reset() + val auto = ToolbarKey.valueOf("AUTO_CAP") + val force = ToolbarKey.valueOf("FORCE_AUTO_CAPS") + val strip = SuggestionStripView(latinIME, null) + val keyboardView = MainKeyboardView(latinIME, null).apply { id = R.id.keyboard_view } + strip.setListener(latinIME, keyboardView) + val imeStripField = LatinIME::class.java.getDeclaredField("suggestionStripView").apply { isAccessible = true } + val switcherStripField = KeyboardSwitcher::class.java.getDeclaredField("mSuggestionStripView").apply { isAccessible = true } + val originalImeStrip = imeStripField.get(latinIME) + val originalSwitcherStrip = switcherStripField.get(latinIME.keyboardSwitcher) + imeStripField.set(latinIME, strip) + switcherStripField.set(latinIME.keyboardSwitcher, strip) + try { + val toolbar = strip.findViewById(R.id.toolbar) + val pinned = strip.findViewById(R.id.pinned_keys) + toolbar.removeAllViews() + pinned.removeAllViews() + val mainAuto = createToolbarKey(latinIME, auto) + val pinnedAuto = createToolbarKey(latinIME, auto) + val mainForce = createToolbarKey(latinIME, force) + val pinnedForce = createToolbarKey(latinIME, force) + listOf(mainAuto, mainForce).forEach { toolbar.addView(it); it.setOnClickListener(strip) } + listOf(pinnedAuto, pinnedForce).forEach { pinned.addView(it); it.setOnClickListener(strip) } + assertEquals("Auto-capitalization", mainAuto.contentDescription) + assertEquals("Force auto-capitalization", mainForce.contentDescription) + assertTrue(mainAuto.isActivated) + assertFalse(mainForce.isActivated) + assertEquals("On", ViewCompat.getStateDescription(mainAuto)) + assertEquals("Off", ViewCompat.getStateDescription(mainForce)) + mainForce.performClick() + assertTrue(mainForce.isActivated) + assertTrue(pinnedForce.isActivated) + pinnedAuto.performClick() + assertFalse(mainAuto.isActivated) + assertFalse(pinnedAuto.isActivated) + assertFalse(mainForce.isActivated) + assertFalse(pinnedForce.isActivated) + assertTrue(latinIME.prefs().getBoolean(Settings.PREF_FORCE_AUTO_CAPS, false)) + assertEquals("Saved on; requires Auto-capitalization", ViewCompat.getStateDescription(mainForce)) + assertEquals(ViewCompat.getStateDescription(mainForce), ViewCompat.getStateDescription(pinnedForce)) + mainAuto.performClick() + assertTrue(mainAuto.isActivated) + assertTrue(pinnedAuto.isActivated) + assertTrue(mainForce.isActivated) + assertTrue(pinnedForce.isActivated) + assertEquals("On", ViewCompat.getStateDescription(pinnedForce)) + } finally { + imeStripField.set(latinIME, originalImeStrip) + switcherStripField.set(latinIME.keyboardSwitcher, originalSwitcherStrip) + } + } + + @Test @Config(sdk = [32, 35]) + fun capsControlsHaveIconsInEveryStyleAndRespectUncasedLocales() { + reset() + val keys = listOf(ToolbarKey.valueOf("AUTO_CAP"), ToolbarKey.valueOf("FORCE_AUTO_CAPS")) + for (style in listOf(KeyboardTheme.STYLE_HOLO, KeyboardTheme.STYLE_MATERIAL, KeyboardTheme.STYLE_ROUNDED)) { + latinIME.prefs().edit { putString(Settings.PREF_ICON_STYLE, style) } + KeyboardIconsSet.instance.loadIcons(latinIME) + keys.forEach { assertNotNull(createToolbarKey(latinIME, it).drawable) } + } + latinIME.prefs().edit { putBoolean(Settings.PREF_FORCE_AUTO_CAPS, true) } + latinIME.settings.loadSettings(latinIME, Locale.KOREAN, settingsValues.mInputAttributes, ScriptUtils.SCRIPT_LATIN) + assertFalse(settingsValues.mAutoCap) + keys.forEach { + val button = createToolbarKey(latinIME, it) + assertFalse(button.isActivated) + assertEquals("Saved on; this language has no uppercase letters", ViewCompat.getStateDescription(button)) + } + assertTrue(latinIME.prefs().getBoolean(Settings.PREF_AUTO_CAP, true)) + assertTrue(latinIME.prefs().getBoolean(Settings.PREF_FORCE_AUTO_CAPS, false)) + } + + @Test @Config(sdk = [32, 35]) + fun capsControlsUpgradeWithoutChangingToolbarOrderOrEnablingNewItems() { + reset() + val keys = listOf(ToolbarKey.valueOf("AUTO_CAP"), ToolbarKey.valueOf("FORCE_AUTO_CAPS")) + assertTrue(keys.none { it in getEnabledToolbarKeys(latinIME.prefs()) || it in getPinnedToolbarKeys(latinIME.prefs()) }) + val enabled = listOf(ToolbarKey.PASTE, ToolbarKey.AUTOCORRECT) + val oldKeys = enabled + ToolbarKey.entries.filter { it !in keys && it !in enabled } + val original = oldKeys.joinToString(Separators.ENTRY) { it.name + Separators.KV + (it in enabled) } + val prefs = listOf(Settings.PREF_TOOLBAR_KEYS, Settings.PREF_PINNED_TOOLBAR_KEYS, Settings.PREF_CLIPBOARD_TOOLBAR_KEYS) + latinIME.prefs().edit { prefs.forEach { putString(it, original) } } + upgradeToolbarPrefs(latinIME.prefs()) + assertEquals(enabled, getEnabledToolbarKeys(latinIME.prefs())) + assertEquals(enabled, getPinnedToolbarKeys(latinIME.prefs())) + assertEquals(enabled, getEnabledClipboardToolbarKeys(latinIME.prefs())) + for (pref in prefs) { + val updated = latinIME.prefs().getString(pref, "")!! + assertTrue(updated.startsWith(original)) + keys.forEach { assertTrue(updated.contains(it.name + Separators.KV + false)) } + } + } + + @Test @Config(sdk = [32, 35]) + fun capsControlHighlightUsesEffectiveEnabledColors() { + reset() + latinIME.prefs().edit { putBoolean(Settings.PREF_FORCE_AUTO_CAPS, true) } + val values = settingsValues + val colorsField = values.javaClass.getDeclaredField("mColors").apply { isAccessible = true } + val originalColors = colorsField.get(values) + val colors = Mockito.mock(helium314.keyboard.latin.common.Colors::class.java) + val buttons = listOf("AUTO_CAP", "FORCE_AUTO_CAPS").map { createToolbarKey(latinIME, ToolbarKey.valueOf(it)) } + colorsField.set(values, colors) + try { + for (button in buttons) { + setToolbarButtonActivatedState(button) + Mockito.verify(colors).setColor(button.background, helium314.keyboard.latin.common.ColorType.TOOL_BAR_KEY_ENABLED_BACKGROUND) + Mockito.clearInvocations(colors) + } + } finally { + colorsField.set(values, originalColors) + } + } + @Test fun `english space-separated typing keeps composing word`() { reset() chainInput("hello") @@ -1265,6 +1535,7 @@ class InputLogicTest { text = "" batchEdit = 0 currentInputType = InputType.TYPE_CLASS_TEXT + editorInfoOverride = null lastAddedWord = "" lastNgramContext = "" addedWords.clear() @@ -1506,6 +1777,7 @@ class InputLogicTest { } private var currentInputType = InputType.TYPE_CLASS_TEXT +private var editorInfoOverride: EditorInfo? = null private var currentScript = ScriptUtils.SCRIPT_LATIN private val messages = mutableListOf() // for latinIME / ShadowInputMethodService private val delayedMessages = mutableListOf() // for latinIME / ShadowInputMethodService @@ -1705,7 +1977,7 @@ private val ic = object : InputConnection { @Implements(InputMethodService::class) class ShadowInputMethodService { @Implementation - fun getCurrentInputEditorInfo() = EditorInfo().apply { + fun getCurrentInputEditorInfo() = editorInfoOverride ?: EditorInfo().apply { inputType = currentInputType // anything else? } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 779455ee9..7b69e6cfe 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -645,6 +645,25 @@ LeanType provides an extensible keycode architecture that allows customizing key > [!TIP] > All negative keycodes listed below are actively recognized and validated by `KeyCode.checkAndConvertCode()`. Positive integer codes correspond to standard Unicode codepoints (or ASCII values like `10` for Enter, `32` for Space, `9` for Tab). +### Capitalization controls + +**Auto-capitalization** (`{"label":"auto_cap"}`, fixed code `-10079`) and +**Force auto-capitalization** (`{"label":"force_auto_caps"}`, fixed code `-10080`) +toggle the existing saved Text correction settings. Enable these optional controls +in the toolbar, pin them, or use them in custom JSON keys and popups. New entries +start disabled; existing toolbar order and custom keycodes are preserved. +Keywords follow customized toolbar codes; an explicit numeric `code` keeps the +fixed action. The force keyword is plural: `force_auto_caps`. + +These settings request sentence capitalization, not manual Shift or Caps Lock. +Toggling preserves manual Shift/Caps Lock, selection, and the current composing +word. Highlights reflect effective enablement, not whether the next character +will be uppercase. Turning Auto-capitalization off also suppresses Force +auto-capitalization without clearing its saved choice. Languages without +uppercase letters suppress both effects. Accessibility state descriptions +distinguish off, on, and saved-on-but-inactive states. Existing field-capitalization +and correction rules are unchanged. + --- ### 1. ✏️ Text Editing & Deletion @@ -752,6 +771,8 @@ LeanType provides an extensible keycode architecture that allows customizing key | **`-233`** | `VOICE_INPUT` | Initiates voice typing (Whisper on-device or system voice IME). | | **`-244`** | `TOGGLE_INCOGNITO_MODE`| Toggles incognito private mode (disables dictionary learning). | | **`-245`** | `TOGGLE_AUTOCORRECT` | Instantly toggles auto-correction on or off. | +| **`-10079`** | `TOGGLE_AUTO_CAP` | Toggles the saved Auto-capitalization setting. | +| **`-10080`** | `TOGGLE_FORCE_AUTO_CAPS` | Toggles the saved Force auto-capitalization setting. | | **`-301`** | `SETTINGS` | Opens LeanType Settings. | | **`-10043`** | `TIMESTAMP` | Inserts the current localized date and time stamp at cursor. | | **`-10051`** | `INLINE_EMOJI_SEARCH_DONE` | Confirms and closes inline emoji search. | @@ -830,4 +851,3 @@ LeanType provides an extensible keycode architecture that allows customizing key | **`-10008`** | `NOT_SPECIFIED` | Structural dummy spacer key (disables key interaction, renders blank). | -