diff --git a/common/src/org/futo/inputmethod/latin/common/StringUtils.java b/common/src/org/futo/inputmethod/latin/common/StringUtils.java index 3ac1429b3c..892e5e4703 100644 --- a/common/src/org/futo/inputmethod/latin/common/StringUtils.java +++ b/common/src/org/futo/inputmethod/latin/common/StringUtils.java @@ -405,6 +405,49 @@ public static boolean isIdenticalAfterCapitalizeEachWord(@Nonnull final String t return true; } + /** + * Finds the word at or immediately before the cursor. + * + * The word is the maximal run of code points which are not in sortedWordSeparators that + * contains the cursor, starts right at the cursor, or ends right at the cursor. + * + * @param textBeforeCursor the text ending right at the cursor position. + * @param textAfterCursor the text starting right at the cursor position. + * @param sortedWordSeparators a sorted array of word separator code points. + * @return int[]{index where the word starts in textBeforeCursor, + * index just past where the word ends in textAfterCursor}, or null if the cursor + * is not adjacent to a word. + */ + @Nullable + public static int[] getWordRangeAtCursor(@Nullable final CharSequence textBeforeCursor, + @Nullable final CharSequence textAfterCursor, + @Nonnull final int[] sortedWordSeparators) { + final int beforeLength = null == textBeforeCursor ? 0 : textBeforeCursor.length(); + int start = beforeLength; + while (start > 0) { + final int codePoint = Character.codePointBefore(textBeforeCursor, start); + if (Arrays.binarySearch(sortedWordSeparators, codePoint) >= 0) { + break; + } + start -= Character.charCount(codePoint); + } + int end = 0; + if (null != textAfterCursor) { + final int afterLength = textAfterCursor.length(); + while (end < afterLength) { + final int codePoint = Character.codePointAt(textAfterCursor, end); + if (Arrays.binarySearch(sortedWordSeparators, codePoint) >= 0) { + break; + } + end += Character.charCount(codePoint); + } + } + if (start == beforeLength && end == 0) { + return null; // The cursor is not adjacent to a word on either side. + } + return new int[] { start, end }; + } + // TODO: like capitalizeFirst*, this does not work perfectly for Dutch because of the IJ digraph // which should be capitalized together in *some* cases. @Nonnull diff --git a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java index fefc54baad..af03e0c997 100644 --- a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java +++ b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java @@ -1208,6 +1208,17 @@ private void handleSeparatorEvent(final Event event, final InputTransaction inpu final int codePoint = event.mCodePoint; final SettingsValues settingsValues = inputTransaction.mSettingsValues; final boolean wasComposingWord = mWordComposer.isComposingWord(); + // A recapitalized word is kept selected so that Shift can cycle its case. If a + // separator is typed while that selection is active, commit the word first by + // collapsing the selection to its end, otherwise the separator would replace the + // selection and delete the word. + if (mRecapitalizeStatus.isStarted() + && mRecapitalizeStatus.isSetAt(mConnection.getExpectedSelectionStart(), + mConnection.getExpectedSelectionEnd())) { + mConnection.setSelection(mRecapitalizeStatus.getNewCursorEnd(), + mRecapitalizeStatus.getNewCursorEnd()); + mRecapitalizeStatus.stop(); + } // We avoid sending spaces in languages without spaces if we were composing. final boolean shouldAvoidSendingCode = Constants.CODE_SPACE == codePoint && !settingsValues.mSpacingAndPunctuations.currentLanguageHasSpaces @@ -1971,11 +1982,43 @@ private static boolean canBeFollowedByDoubleSpacePeriod(final int codePoint) { * @param settingsValues The current settings values. */ private void performRecapitalization(final SettingsValues settingsValues) { - if (!mConnection.hasSelection() || !mRecapitalizeStatus.mIsEnabled()) { - return; // No selection or recapitalize is disabled for now - } - final int selectionStart = mConnection.getExpectedSelectionStart(); - final int selectionEnd = mConnection.getExpectedSelectionEnd(); + int selectionStart = mConnection.getExpectedSelectionStart(); + int selectionEnd = mConnection.getExpectedSelectionEnd(); + CharSequence textToRecapitalize; + if (mConnection.hasSelection()) { + if (!mRecapitalizeStatus.mIsEnabled()) return; + textToRecapitalize = mConnection.getSelectedText(0 /* flags, 0 for no styles */); + } else { + if (!mConnection.isCursorPositionKnown()) return; + final CharSequence textBeforeCursor = mConnection.getTextBeforeCursor( + Constants.MAX_CHARACTERS_FOR_RECAPITALIZATION, 0 /* flags */); + final CharSequence textAfterCursor = mConnection.getTextAfterCursor( + Constants.MAX_CHARACTERS_FOR_RECAPITALIZATION, 0 /* flags */); + if (textBeforeCursor == null || textAfterCursor == null) return; + final int[] wordRange = StringUtils.getWordRangeAtCursor(textBeforeCursor, + textAfterCursor, + settingsValues.mSpacingAndPunctuations.sortedWordSeparators); + if (wordRange == null) return; + // A full buffer ending inside a word may have omitted part of that word. Refuse to + // recapitalize a partial word rather than silently changing only a suffix or prefix. + if ((wordRange[0] == 0 + && textBeforeCursor.length() == Constants.MAX_CHARACTERS_FOR_RECAPITALIZATION) + || (wordRange[1] == textAfterCursor.length() + && textAfterCursor.length() == Constants.MAX_CHARACTERS_FOR_RECAPITALIZATION)) { + return; + } + // Existing recapitalization waits for a cursor-move callback after starting input. + // Cursor-only recapitalization has independently validated the current cursor and + // surrounding text, so it is safe to enable without requiring that extra movement. + mRecapitalizeStatus.enable(); + selectionStart -= textBeforeCursor.length() - wordRange[0]; + selectionEnd += wordRange[1]; + textToRecapitalize = textBeforeCursor.subSequence(wordRange[0], + textBeforeCursor.length()).toString() + + textAfterCursor.subSequence(0, wordRange[1]); + } + if (TextUtils.isEmpty(textToRecapitalize)) return; // Race condition with the input connection + final int numCharsSelected = selectionEnd - selectionStart; if (numCharsSelected > Constants.MAX_CHARACTERS_FOR_RECAPITALIZATION) { // We bail out if we have too many characters for performance reasons. We don't want @@ -1985,10 +2028,7 @@ private void performRecapitalization(final SettingsValues settingsValues) { // If we have a recapitalize in progress, use it; otherwise, start a new one. if (!mRecapitalizeStatus.isStarted() || !mRecapitalizeStatus.isSetAt(selectionStart, selectionEnd)) { - final CharSequence selectedText = - mConnection.getSelectedText(0 /* flags, 0 for no styles */); - if (TextUtils.isEmpty(selectedText)) return; // Race condition with the input connection - mRecapitalizeStatus.start(selectionStart, selectionEnd, selectedText.toString(), + mRecapitalizeStatus.start(selectionStart, selectionEnd, textToRecapitalize.toString(), settingsValues.mLocale, settingsValues.mSpacingAndPunctuations.sortedWordSeparators); // We trim leading and trailing whitespace. @@ -2002,6 +2042,7 @@ private void performRecapitalization(final SettingsValues settingsValues) { mConnection.send(); mConnection.setSelection(mRecapitalizeStatus.getNewCursorStart(), mRecapitalizeStatus.getNewCursorEnd()); + resetComposingState(true /* alsoResetLastComposedWord */); } private void performAdditionToUserHistoryDictionary(final SettingsValues settingsValues, diff --git a/tests/src/org/futo/inputmethod/latin/InputLogicTests.java b/tests/src/org/futo/inputmethod/latin/InputLogicTests.java index a033ccba6b..b908f52aac 100644 --- a/tests/src/org/futo/inputmethod/latin/InputLogicTests.java +++ b/tests/src/org/futo/inputmethod/latin/InputLogicTests.java @@ -216,6 +216,44 @@ public void testAutoCorrectToSelfDoesNotRevert() { mEditText.getText().toString()); } + public void testRecapitalizeThenSpaceDoesNotAutoCorrect() { + final String WORD_TO_TYPE = "tgis"; + final String EXPECTED_AFTER_RECAP = "Tgis"; + final String EXPECTED_RESULT = "Tgis "; + type(WORD_TO_TYPE); + type(Constants.CODE_SHIFT); + assertEquals("shift should recapitalize the word at the cursor", + EXPECTED_AFTER_RECAP, mEditText.getText().toString()); + type(Constants.CODE_SPACE); + assertEquals("space should commit the recapitalized word, not a stale auto-correction", + EXPECTED_RESULT, mEditText.getText().toString()); + } + + public void testRecapitalizeShiftCyclingThenSpace() { + final String WORD_TO_TYPE = "tgis"; + type(WORD_TO_TYPE); + type(Constants.CODE_SHIFT); + assertEquals("first shift should recapitalize the word at the cursor", + "Tgis", mEditText.getText().toString()); + type(Constants.CODE_SHIFT); + assertEquals("second shift should cycle the case of the selected recapitalized word", + "TGIS", mEditText.getText().toString()); + type(Constants.CODE_SPACE); + assertEquals("space should commit the cycled recapitalized word", + "TGIS ", mEditText.getText().toString()); + } + + public void testRecapitalizeThenPunctuation() { + final String WORD_TO_TYPE = "tgis"; + type(WORD_TO_TYPE); + type(Constants.CODE_SHIFT); + assertEquals("shift should recapitalize the word at the cursor", + "Tgis", mEditText.getText().toString()); + type(Constants.CODE_PERIOD); + assertEquals("punctuation should commit the recapitalized word", + "Tgis.", mEditText.getText().toString()); + } + public void testDoubleSpace() { // U+1F607 is an emoji final String[] STRINGS_TO_TYPE = diff --git a/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java b/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java index 44a5214d52..b2cc936e06 100644 --- a/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java +++ b/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java @@ -35,6 +35,29 @@ public class StringUtilsTests { private static final Locale GERMAN = Locale.GERMAN; private static final Locale TURKEY = new Locale("tr", "TR"); private static final Locale GREECE = new Locale("el", "GR"); + private static final int[] RECAPITALIZE_WORD_SEPARATORS = + StringUtils.toSortedCodePointArray(" .,!"); + + private static void assertWordRange(final String before, final String after, + final int expectedStart, final int expectedEnd) { + final int[] range = StringUtils.getWordRangeAtCursor(before, after, + RECAPITALIZE_WORD_SEPARATORS); + assertEquals(expectedStart, range[0]); + assertEquals(expectedEnd, range[1]); + } + + @Test + public void testGetWordRangeAtCursor() { + assertWordRange("hello", "", 0, 0); + assertWordRange("hel", "lo world", 0, 2); + assertWordRange("hello ", "world", 6, 5); + assertWordRange("", "café!", 0, 4); + assertWordRange("go😀", "!", 0, 0); + assertEquals(null, StringUtils.getWordRangeAtCursor("hello ", ". world", + RECAPITALIZE_WORD_SEPARATORS)); + assertEquals(null, StringUtils.getWordRangeAtCursor("hello ", "", + RECAPITALIZE_WORD_SEPARATORS)); + } private static void assert_toTitleCaseOfKeyLabel(final Locale locale, final String lowerCase, final String expected) {