1 /* 2 * Copyright (C) 2008 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.inputmethod.latin; 18 19 import com.android.inputmethod.annotations.UsedForTesting; 20 import com.android.inputmethod.event.CombinerChain; 21 import com.android.inputmethod.event.Event; 22 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo; 23 import com.android.inputmethod.latin.common.ComposedData; 24 import com.android.inputmethod.latin.common.Constants; 25 import com.android.inputmethod.latin.common.CoordinateUtils; 26 import com.android.inputmethod.latin.common.InputPointers; 27 import com.android.inputmethod.latin.common.StringUtils; 28 import com.android.inputmethod.latin.define.DebugFlags; 29 import com.android.inputmethod.latin.define.DecoderSpecificConstants; 30 31 import java.util.ArrayList; 32 import java.util.Collections; 33 34 import javax.annotation.Nonnull; 35 36 /** 37 * A place to store the currently composing word with information such as adjacent key codes as well 38 */ 39 public final class WordComposer { 40 private static final int MAX_WORD_LENGTH = DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH; 41 private static final boolean DBG = DebugFlags.DEBUG_ENABLED; 42 43 public static final int CAPS_MODE_OFF = 0; 44 // 1 is shift bit, 2 is caps bit, 4 is auto bit but this is just a convention as these bits 45 // aren't used anywhere in the code 46 public static final int CAPS_MODE_MANUAL_SHIFTED = 0x1; 47 public static final int CAPS_MODE_MANUAL_SHIFT_LOCKED = 0x3; 48 public static final int CAPS_MODE_AUTO_SHIFTED = 0x5; 49 public static final int CAPS_MODE_AUTO_SHIFT_LOCKED = 0x7; 50 51 private CombinerChain mCombinerChain; 52 private String mCombiningSpec; // Memory so that we don't uselessly recreate the combiner chain 53 54 // The list of events that served to compose this string. 55 private final ArrayList<Event> mEvents; 56 private final InputPointers mInputPointers = new InputPointers(MAX_WORD_LENGTH); 57 private SuggestedWordInfo mAutoCorrection; 58 private boolean mIsResumed; 59 private boolean mIsBatchMode; 60 // A memory of the last rejected batch mode suggestion, if any. This goes like this: the user 61 // gestures a word, is displeased with the results and hits backspace, then gestures again. 62 // At the very least we should avoid re-suggesting the same thing, and to do that we memorize 63 // the rejected suggestion in this variable. 64 // TODO: this should be done in a comprehensive way by the User History feature instead of 65 // as an ad-hockery here. 66 private String mRejectedBatchModeSuggestion; 67 68 // Cache these values for performance 69 private CharSequence mTypedWordCache; 70 private int mCapsCount; 71 private int mDigitsCount; 72 private int mCapitalizedMode; 73 // This is the number of code points entered so far. This is not limited to MAX_WORD_LENGTH. 74 // In general, this contains the size of mPrimaryKeyCodes, except when this is greater than 75 // MAX_WORD_LENGTH in which case mPrimaryKeyCodes only contain the first MAX_WORD_LENGTH 76 // code points. 77 private int mCodePointSize; 78 private int mCursorPositionWithinWord; 79 80 /** 81 * Whether the composing word has the only first char capitalized. 82 */ 83 private boolean mIsOnlyFirstCharCapitalized; 84 WordComposer()85 public WordComposer() { 86 mCombinerChain = new CombinerChain(""); 87 mEvents = new ArrayList<>(); 88 mAutoCorrection = null; 89 mIsResumed = false; 90 mIsBatchMode = false; 91 mCursorPositionWithinWord = 0; 92 mRejectedBatchModeSuggestion = null; 93 refreshTypedWordCache(); 94 } 95 getComposedDataSnapshot()96 public ComposedData getComposedDataSnapshot() { 97 return new ComposedData(getInputPointers(), isBatchMode(), mTypedWordCache.toString()); 98 } 99 100 /** 101 * Restart the combiners, possibly with a new spec. 102 * @param combiningSpec The spec string for combining. This is found in the extra value. 103 */ restartCombining(final String combiningSpec)104 public void restartCombining(final String combiningSpec) { 105 final String nonNullCombiningSpec = null == combiningSpec ? "" : combiningSpec; 106 if (!nonNullCombiningSpec.equals(mCombiningSpec)) { 107 mCombinerChain = new CombinerChain( 108 mCombinerChain.getComposingWordWithCombiningFeedback().toString()); 109 mCombiningSpec = nonNullCombiningSpec; 110 } 111 } 112 113 /** 114 * Clear out the keys registered so far. 115 */ reset()116 public void reset() { 117 mCombinerChain.reset(); 118 mEvents.clear(); 119 mAutoCorrection = null; 120 mCapsCount = 0; 121 mDigitsCount = 0; 122 mIsOnlyFirstCharCapitalized = false; 123 mIsResumed = false; 124 mIsBatchMode = false; 125 mCursorPositionWithinWord = 0; 126 mRejectedBatchModeSuggestion = null; 127 refreshTypedWordCache(); 128 } 129 refreshTypedWordCache()130 private final void refreshTypedWordCache() { 131 mTypedWordCache = mCombinerChain.getComposingWordWithCombiningFeedback(); 132 mCodePointSize = Character.codePointCount(mTypedWordCache, 0, mTypedWordCache.length()); 133 } 134 135 /** 136 * Number of keystrokes in the composing word. 137 * @return the number of keystrokes 138 */ size()139 public int size() { 140 return mCodePointSize; 141 } 142 isSingleLetter()143 public boolean isSingleLetter() { 144 return size() == 1; 145 } 146 isComposingWord()147 public final boolean isComposingWord() { 148 return size() > 0; 149 } 150 getInputPointers()151 public InputPointers getInputPointers() { 152 return mInputPointers; 153 } 154 155 /** 156 * Process an event and return an event, and return a processed event to apply. 157 * @param event the unprocessed event. 158 * @return the processed event. Never null, but may be marked as consumed. 159 */ 160 @Nonnull processEvent(@onnull final Event event)161 public Event processEvent(@Nonnull final Event event) { 162 final Event processedEvent = mCombinerChain.processEvent(mEvents, event); 163 // The retained state of the combiner chain may have changed while processing the event, 164 // so we need to update our cache. 165 refreshTypedWordCache(); 166 mEvents.add(event); 167 return processedEvent; 168 } 169 170 /** 171 * Apply a processed input event. 172 * 173 * All input events should be supported, including software/hardware events, characters as well 174 * as deletions, multiple inputs and gestures. 175 * 176 * @param event the event to apply. Must not be null. 177 */ applyProcessedEvent(final Event event)178 public void applyProcessedEvent(final Event event) { 179 mCombinerChain.applyProcessedEvent(event); 180 final int primaryCode = event.mCodePoint; 181 final int keyX = event.mX; 182 final int keyY = event.mY; 183 final int newIndex = size(); 184 refreshTypedWordCache(); 185 mCursorPositionWithinWord = mCodePointSize; 186 // We may have deleted the last one. 187 if (0 == mCodePointSize) { 188 mIsOnlyFirstCharCapitalized = false; 189 } 190 if (Constants.CODE_DELETE != event.mKeyCode) { 191 if (newIndex < MAX_WORD_LENGTH) { 192 // In the batch input mode, the {@code mInputPointers} holds batch input points and 193 // shouldn't be overridden by the "typed key" coordinates 194 // (See {@link #setBatchInputWord}). 195 if (!mIsBatchMode) { 196 // TODO: Set correct pointer id and time 197 mInputPointers.addPointerAt(newIndex, keyX, keyY, 0, 0); 198 } 199 } 200 if (0 == newIndex) { 201 mIsOnlyFirstCharCapitalized = Character.isUpperCase(primaryCode); 202 } else { 203 mIsOnlyFirstCharCapitalized = mIsOnlyFirstCharCapitalized 204 && !Character.isUpperCase(primaryCode); 205 } 206 if (Character.isUpperCase(primaryCode)) mCapsCount++; 207 if (Character.isDigit(primaryCode)) mDigitsCount++; 208 } 209 mAutoCorrection = null; 210 } 211 setCursorPositionWithinWord(final int posWithinWord)212 public void setCursorPositionWithinWord(final int posWithinWord) { 213 mCursorPositionWithinWord = posWithinWord; 214 // TODO: compute where that puts us inside the events 215 } 216 isCursorFrontOrMiddleOfComposingWord()217 public boolean isCursorFrontOrMiddleOfComposingWord() { 218 if (DBG && mCursorPositionWithinWord > mCodePointSize) { 219 throw new RuntimeException("Wrong cursor position : " + mCursorPositionWithinWord 220 + "in a word of size " + mCodePointSize); 221 } 222 return mCursorPositionWithinWord != mCodePointSize; 223 } 224 225 /** 226 * When the cursor is moved by the user, we need to update its position. 227 * If it falls inside the currently composing word, we don't reset the composition, and 228 * only update the cursor position. 229 * 230 * @param expectedMoveAmount How many java chars to move the cursor. Negative values move 231 * the cursor backward, positive values move the cursor forward. 232 * @return true if the cursor is still inside the composing word, false otherwise. 233 */ moveCursorByAndReturnIfInsideComposingWord(final int expectedMoveAmount)234 public boolean moveCursorByAndReturnIfInsideComposingWord(final int expectedMoveAmount) { 235 int actualMoveAmount = 0; 236 int cursorPos = mCursorPositionWithinWord; 237 // TODO: Don't make that copy. We can do this directly from mTypedWordCache. 238 final int[] codePoints = StringUtils.toCodePointArray(mTypedWordCache); 239 if (expectedMoveAmount >= 0) { 240 // Moving the cursor forward for the expected amount or until the end of the word has 241 // been reached, whichever comes first. 242 while (actualMoveAmount < expectedMoveAmount && cursorPos < codePoints.length) { 243 actualMoveAmount += Character.charCount(codePoints[cursorPos]); 244 ++cursorPos; 245 } 246 } else { 247 // Moving the cursor backward for the expected amount or until the start of the word 248 // has been reached, whichever comes first. 249 while (actualMoveAmount > expectedMoveAmount && cursorPos > 0) { 250 --cursorPos; 251 actualMoveAmount -= Character.charCount(codePoints[cursorPos]); 252 } 253 } 254 // If the actual and expected amounts differ, we crossed the start or the end of the word 255 // so the result would not be inside the composing word. 256 if (actualMoveAmount != expectedMoveAmount) { 257 return false; 258 } 259 mCursorPositionWithinWord = cursorPos; 260 mCombinerChain.applyProcessedEvent(mCombinerChain.processEvent( 261 mEvents, Event.createCursorMovedEvent(cursorPos))); 262 return true; 263 } 264 setBatchInputPointers(final InputPointers batchPointers)265 public void setBatchInputPointers(final InputPointers batchPointers) { 266 mInputPointers.set(batchPointers); 267 mIsBatchMode = true; 268 } 269 setBatchInputWord(final String word)270 public void setBatchInputWord(final String word) { 271 reset(); 272 mIsBatchMode = true; 273 final int length = word.length(); 274 for (int i = 0; i < length; i = Character.offsetByCodePoints(word, i, 1)) { 275 final int codePoint = Character.codePointAt(word, i); 276 // We don't want to override the batch input points that are held in mInputPointers 277 // (See {@link #add(int,int,int)}). 278 final Event processedEvent = 279 processEvent(Event.createEventForCodePointFromUnknownSource(codePoint)); 280 applyProcessedEvent(processedEvent); 281 } 282 } 283 284 /** 285 * Set the currently composing word to the one passed as an argument. 286 * This will register NOT_A_COORDINATE for X and Ys, and use the passed keyboard for proximity. 287 * @param codePoints the code points to set as the composing word. 288 * @param coordinates the x, y coordinates of the key in the CoordinateUtils format 289 */ setComposingWord(final int[] codePoints, final int[] coordinates)290 public void setComposingWord(final int[] codePoints, final int[] coordinates) { 291 reset(); 292 final int length = codePoints.length; 293 for (int i = 0; i < length; ++i) { 294 final Event processedEvent = 295 processEvent(Event.createEventForCodePointFromAlreadyTypedText(codePoints[i], 296 CoordinateUtils.xFromArray(coordinates, i), 297 CoordinateUtils.yFromArray(coordinates, i))); 298 applyProcessedEvent(processedEvent); 299 } 300 mIsResumed = true; 301 } 302 303 /** 304 * Returns the word as it was typed, without any correction applied. 305 * @return the word that was typed so far. Never returns null. 306 */ getTypedWord()307 public String getTypedWord() { 308 return mTypedWordCache.toString(); 309 } 310 311 /** 312 * Whether this composer is composing or about to compose a word in which only the first letter 313 * is a capital. 314 * 315 * If we do have a composing word, we just return whether the word has indeed only its first 316 * character capitalized. If we don't, then we return a value based on the capitalized mode, 317 * which tell us what is likely to happen for the next composing word. 318 * 319 * @return capitalization preference 320 */ isOrWillBeOnlyFirstCharCapitalized()321 public boolean isOrWillBeOnlyFirstCharCapitalized() { 322 return isComposingWord() ? mIsOnlyFirstCharCapitalized 323 : (CAPS_MODE_OFF != mCapitalizedMode); 324 } 325 326 /** 327 * Whether or not all of the user typed chars are upper case 328 * @return true if all user typed chars are upper case, false otherwise 329 */ isAllUpperCase()330 public boolean isAllUpperCase() { 331 if (size() <= 1) { 332 return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED 333 || mCapitalizedMode == CAPS_MODE_MANUAL_SHIFT_LOCKED; 334 } 335 return mCapsCount == size(); 336 } 337 wasShiftedNoLock()338 public boolean wasShiftedNoLock() { 339 return mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED 340 || mCapitalizedMode == CAPS_MODE_MANUAL_SHIFTED; 341 } 342 343 /** 344 * Returns true if more than one character is upper case, otherwise returns false. 345 */ isMostlyCaps()346 public boolean isMostlyCaps() { 347 return mCapsCount > 1; 348 } 349 350 /** 351 * Returns true if we have digits in the composing word. 352 */ hasDigits()353 public boolean hasDigits() { 354 return mDigitsCount > 0; 355 } 356 357 /** 358 * Saves the caps mode at the start of composing. 359 * 360 * WordComposer needs to know about the caps mode for several reasons. The first is, we need 361 * to know after the fact what the reason was, to register the correct form into the user 362 * history dictionary: if the word was automatically capitalized, we should insert it in 363 * all-lower case but if it's a manual pressing of shift, then it should be inserted as is. 364 * Also, batch input needs to know about the current caps mode to display correctly 365 * capitalized suggestions. 366 * @param mode the mode at the time of start 367 */ setCapitalizedModeAtStartComposingTime(final int mode)368 public void setCapitalizedModeAtStartComposingTime(final int mode) { 369 mCapitalizedMode = mode; 370 } 371 372 /** 373 * Before fetching suggestions, we don't necessarily know about the capitalized mode yet. 374 * 375 * If we don't have a composing word yet, we take a note of this mode so that we can then 376 * supply this information to the suggestion process. If we have a composing word, then 377 * the previous mode has priority over this. 378 * @param mode the mode just before fetching suggestions 379 */ adviseCapitalizedModeBeforeFetchingSuggestions(final int mode)380 public void adviseCapitalizedModeBeforeFetchingSuggestions(final int mode) { 381 if (!isComposingWord()) { 382 mCapitalizedMode = mode; 383 } 384 } 385 386 /** 387 * Returns whether the word was automatically capitalized. 388 * @return whether the word was automatically capitalized 389 */ wasAutoCapitalized()390 public boolean wasAutoCapitalized() { 391 return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED 392 || mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED; 393 } 394 395 /** 396 * Sets the auto-correction for this word. 397 */ setAutoCorrection(final SuggestedWordInfo autoCorrection)398 public void setAutoCorrection(final SuggestedWordInfo autoCorrection) { 399 mAutoCorrection = autoCorrection; 400 } 401 402 /** 403 * @return the auto-correction for this word, or null if none. 404 */ getAutoCorrectionOrNull()405 public SuggestedWordInfo getAutoCorrectionOrNull() { 406 return mAutoCorrection; 407 } 408 409 /** 410 * @return whether we started composing this word by resuming suggestion on an existing string 411 */ isResumed()412 public boolean isResumed() { 413 return mIsResumed; 414 } 415 416 // `type' should be one of the LastComposedWord.COMMIT_TYPE_* constants above. 417 // committedWord should contain suggestion spans if applicable. commitWord(final int type, final CharSequence committedWord, final String separatorString, final NgramContext ngramContext)418 public LastComposedWord commitWord(final int type, final CharSequence committedWord, 419 final String separatorString, final NgramContext ngramContext) { 420 // Note: currently, we come here whenever we commit a word. If it's a MANUAL_PICK 421 // or a DECIDED_WORD we may cancel the commit later; otherwise, we should deactivate 422 // the last composed word to ensure this does not happen. 423 final LastComposedWord lastComposedWord = new LastComposedWord(mEvents, 424 mInputPointers, mTypedWordCache.toString(), committedWord, separatorString, 425 ngramContext, mCapitalizedMode); 426 mInputPointers.reset(); 427 if (type != LastComposedWord.COMMIT_TYPE_DECIDED_WORD 428 && type != LastComposedWord.COMMIT_TYPE_MANUAL_PICK) { 429 lastComposedWord.deactivate(); 430 } 431 mCapsCount = 0; 432 mDigitsCount = 0; 433 mIsBatchMode = false; 434 mCombinerChain.reset(); 435 mEvents.clear(); 436 mCodePointSize = 0; 437 mIsOnlyFirstCharCapitalized = false; 438 mCapitalizedMode = CAPS_MODE_OFF; 439 refreshTypedWordCache(); 440 mAutoCorrection = null; 441 mCursorPositionWithinWord = 0; 442 mIsResumed = false; 443 mRejectedBatchModeSuggestion = null; 444 return lastComposedWord; 445 } 446 resumeSuggestionOnLastComposedWord(final LastComposedWord lastComposedWord)447 public void resumeSuggestionOnLastComposedWord(final LastComposedWord lastComposedWord) { 448 mEvents.clear(); 449 Collections.copy(mEvents, lastComposedWord.mEvents); 450 mInputPointers.set(lastComposedWord.mInputPointers); 451 mCombinerChain.reset(); 452 refreshTypedWordCache(); 453 mCapitalizedMode = lastComposedWord.mCapitalizedMode; 454 mAutoCorrection = null; // This will be filled by the next call to updateSuggestion. 455 mCursorPositionWithinWord = mCodePointSize; 456 mRejectedBatchModeSuggestion = null; 457 mIsResumed = true; 458 } 459 isBatchMode()460 public boolean isBatchMode() { 461 return mIsBatchMode; 462 } 463 setRejectedBatchModeSuggestion(final String rejectedSuggestion)464 public void setRejectedBatchModeSuggestion(final String rejectedSuggestion) { 465 mRejectedBatchModeSuggestion = rejectedSuggestion; 466 } 467 getRejectedBatchModeSuggestion()468 public String getRejectedBatchModeSuggestion() { 469 return mRejectedBatchModeSuggestion; 470 } 471 472 @UsedForTesting addInputPointerForTest(int index, int keyX, int keyY)473 void addInputPointerForTest(int index, int keyX, int keyY) { 474 mInputPointers.addPointerAt(index, keyX, keyY, 0, 0); 475 } 476 477 @UsedForTesting setTypedWordCacheForTests(String typedWordCacheForTests)478 void setTypedWordCacheForTests(String typedWordCacheForTests) { 479 mTypedWordCache = typedWordCacheForTests; 480 } 481 } 482