From 1d7e697da374bed3bdd36baaf3ec200a9b07c21c Mon Sep 17 00:00:00 2001 From: djurcola <59278980+djurcola@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:04:02 +1000 Subject: [PATCH 1/5] feat: recapitalize word at cursor --- .../inputmethod/latin/common/StringUtils.java | 43 +++++++++++++++++++ .../latin/inputlogic/InputLogic.java | 42 ++++++++++++++---- .../latin/common/StringUtilsTests.java | 20 +++++++++ 3 files changed, 97 insertions(+), 8 deletions(-) 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..2c085ca8e3 100644 --- a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java +++ b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java @@ -1971,11 +1971,40 @@ 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 + if (!mRecapitalizeStatus.mIsEnabled()) return; + + int selectionStart = mConnection.getExpectedSelectionStart(); + int selectionEnd = mConnection.getExpectedSelectionEnd(); + CharSequence textToRecapitalize; + if (mConnection.hasSelection()) { + 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; + } + selectionStart -= textBeforeCursor.length() - wordRange[0]; + selectionEnd += wordRange[1]; + textToRecapitalize = textBeforeCursor.subSequence(wordRange[0], + textBeforeCursor.length()).toString() + + textAfterCursor.subSequence(0, wordRange[1]); } - final int selectionStart = mConnection.getExpectedSelectionStart(); - final int selectionEnd = mConnection.getExpectedSelectionEnd(); + 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 +2014,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. diff --git a/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java b/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java index 44a5214d52..ece18ba86a 100644 --- a/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java +++ b/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java @@ -35,6 +35,26 @@ 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[] 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, 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", + WORD_SEPARATORS)); + assertEquals(null, StringUtils.getWordRangeAtCursor("hello ", "", WORD_SEPARATORS)); + } private static void assert_toTitleCaseOfKeyLabel(final Locale locale, final String lowerCase, final String expected) { From d8b7e885d050cad07149b2706f137cb9c8636031 Mon Sep 17 00:00:00 2001 From: djurcola <59278980+djurcola@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:05:16 +1000 Subject: [PATCH 2/5] test: cover recapitalization word boundaries --- .../inputmethod/latin/common/StringUtilsTests.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java b/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java index ece18ba86a..b2cc936e06 100644 --- a/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java +++ b/tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java @@ -35,11 +35,13 @@ 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[] WORD_SEPARATORS = StringUtils.toSortedCodePointArray(" .,!"); + 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, WORD_SEPARATORS); + final int[] range = StringUtils.getWordRangeAtCursor(before, after, + RECAPITALIZE_WORD_SEPARATORS); assertEquals(expectedStart, range[0]); assertEquals(expectedEnd, range[1]); } @@ -52,8 +54,9 @@ public void testGetWordRangeAtCursor() { assertWordRange("", "café!", 0, 4); assertWordRange("go😀", "!", 0, 0); assertEquals(null, StringUtils.getWordRangeAtCursor("hello ", ". world", - WORD_SEPARATORS)); - assertEquals(null, StringUtils.getWordRangeAtCursor("hello ", "", WORD_SEPARATORS)); + RECAPITALIZE_WORD_SEPARATORS)); + assertEquals(null, StringUtils.getWordRangeAtCursor("hello ", "", + RECAPITALIZE_WORD_SEPARATORS)); } private static void assert_toTitleCaseOfKeyLabel(final Locale locale, From e11e947b163f57b2e070744b8c433093fa20edbf Mon Sep 17 00:00:00 2001 From: djurcola <59278980+djurcola@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:26:28 +1000 Subject: [PATCH 3/5] fix: enable cursor recapitalization after input start --- .../org/futo/inputmethod/latin/inputlogic/InputLogic.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java index 2c085ca8e3..e70fd6019b 100644 --- a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java +++ b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java @@ -1971,12 +1971,11 @@ private static boolean canBeFollowedByDoubleSpacePeriod(final int codePoint) { * @param settingsValues The current settings values. */ private void performRecapitalization(final SettingsValues settingsValues) { - if (!mRecapitalizeStatus.mIsEnabled()) return; - 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; @@ -1997,6 +1996,10 @@ private void performRecapitalization(final SettingsValues settingsValues) { && 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], From 9f4b9d9d689f86ae10de8faaf0228c5a5e749f61 Mon Sep 17 00:00:00 2001 From: djurcola <59278980+djurcola@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:30:51 +1000 Subject: [PATCH 4/5] fix: preserve recapitalized word when committing space --- .../inputmethod/latin/inputlogic/InputLogic.java | 1 + .../org/futo/inputmethod/latin/InputLogicTests.java | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java index e70fd6019b..c1a45123b4 100644 --- a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java +++ b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java @@ -2031,6 +2031,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..cbcb6ce889 100644 --- a/tests/src/org/futo/inputmethod/latin/InputLogicTests.java +++ b/tests/src/org/futo/inputmethod/latin/InputLogicTests.java @@ -216,6 +216,19 @@ 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 testDoubleSpace() { // U+1F607 is an emoji final String[] STRINGS_TO_TYPE = From 73554c09e1f77384147c94a6f86bb721602535c1 Mon Sep 17 00:00:00 2001 From: djurcola <59278980+djurcola@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:59:59 +1000 Subject: [PATCH 5/5] fix: commit recapitalized selection before separators --- .../latin/inputlogic/InputLogic.java | 11 ++++++++ .../inputmethod/latin/InputLogicTests.java | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java index c1a45123b4..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 diff --git a/tests/src/org/futo/inputmethod/latin/InputLogicTests.java b/tests/src/org/futo/inputmethod/latin/InputLogicTests.java index cbcb6ce889..b908f52aac 100644 --- a/tests/src/org/futo/inputmethod/latin/InputLogicTests.java +++ b/tests/src/org/futo/inputmethod/latin/InputLogicTests.java @@ -229,6 +229,31 @@ public void testRecapitalizeThenSpaceDoesNotAutoCorrect() { 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 =