Skip to content
Open
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
43 changes: 43 additions & 0 deletions common/src/org/futo/inputmethod/latin/common/StringUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 50 additions & 9 deletions java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions tests/src/org/futo/inputmethod/latin/InputLogicTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
23 changes: 23 additions & 0 deletions tests/src/org/futo/inputmethod/latin/common/StringUtilsTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down