WordComposer.java revision 0de7a6d1a272d52a9544df1c693ae199ab5abc52
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 17package com.android.inputmethod.latin; 18 19import com.android.inputmethod.event.CombinerChain; 20import com.android.inputmethod.event.Event; 21import com.android.inputmethod.latin.utils.CollectionUtils; 22import com.android.inputmethod.latin.utils.CoordinateUtils; 23import com.android.inputmethod.latin.utils.StringUtils; 24 25import java.util.ArrayList; 26import java.util.Arrays; 27import java.util.Collections; 28 29/** 30 * A place to store the currently composing word with information such as adjacent key codes as well 31 */ 32public final class WordComposer { 33 private static final int MAX_WORD_LENGTH = Constants.DICTIONARY_MAX_WORD_LENGTH; 34 private static final boolean DBG = LatinImeLogger.sDBG; 35 36 public static final int CAPS_MODE_OFF = 0; 37 // 1 is shift bit, 2 is caps bit, 4 is auto bit but this is just a convention as these bits 38 // aren't used anywhere in the code 39 public static final int CAPS_MODE_MANUAL_SHIFTED = 0x1; 40 public static final int CAPS_MODE_MANUAL_SHIFT_LOCKED = 0x3; 41 public static final int CAPS_MODE_AUTO_SHIFTED = 0x5; 42 public static final int CAPS_MODE_AUTO_SHIFT_LOCKED = 0x7; 43 44 private CombinerChain mCombinerChain; 45 46 // The list of events that served to compose this string. 47 private final ArrayList<Event> mEvents; 48 private final InputPointers mInputPointers = new InputPointers(MAX_WORD_LENGTH); 49 // This is the typed word, as a StringBuilder. This has the same contents as mPrimaryKeyCodes 50 // but under a StringBuilder representation for ease of use, depending on what is more useful 51 // at any given time. However this is not limited in size, while mPrimaryKeyCodes is limited 52 // to MAX_WORD_LENGTH code points. 53 private final StringBuilder mTypedWord; 54 // The previous word (before the composing word). Used as context for suggestions. May be null 55 // after resetting and before starting a new composing word, or when there is no context like 56 // at the start of text for example. It can also be set to null externally when the user 57 // enters a separator that does not let bigrams across, like a period or a comma. 58 private String mPreviousWordForSuggestion; 59 private String mAutoCorrection; 60 private boolean mIsResumed; 61 private boolean mIsBatchMode; 62 // A memory of the last rejected batch mode suggestion, if any. This goes like this: the user 63 // gestures a word, is displeased with the results and hits backspace, then gestures again. 64 // At the very least we should avoid re-suggesting the same thing, and to do that we memorize 65 // the rejected suggestion in this variable. 66 // TODO: this should be done in a comprehensive way by the User History feature instead of 67 // as an ad-hockery here. 68 private String mRejectedBatchModeSuggestion; 69 70 // Cache these values for performance 71 private int mCapsCount; 72 private int mDigitsCount; 73 private int mCapitalizedMode; 74 private int mTrailingSingleQuotesCount; 75 // This is the number of code points entered so far. This is not limited to MAX_WORD_LENGTH. 76 // In general, this contains the size of mPrimaryKeyCodes, except when this is greater than 77 // MAX_WORD_LENGTH in which case mPrimaryKeyCodes only contain the first MAX_WORD_LENGTH 78 // code points. 79 private int mCodePointSize; 80 private int mCursorPositionWithinWord; 81 82 /** 83 * Whether the user chose to capitalize the first char of the word. 84 */ 85 private boolean mIsFirstCharCapitalized; 86 87 public WordComposer() { 88 mCombinerChain = new CombinerChain(); 89 mEvents = CollectionUtils.newArrayList(); 90 mTypedWord = new StringBuilder(MAX_WORD_LENGTH); 91 mAutoCorrection = null; 92 mTrailingSingleQuotesCount = 0; 93 mIsResumed = false; 94 mIsBatchMode = false; 95 mCursorPositionWithinWord = 0; 96 mRejectedBatchModeSuggestion = null; 97 mPreviousWordForSuggestion = null; 98 refreshSize(); 99 } 100 101 public WordComposer(final WordComposer source) { 102 mCombinerChain = source.mCombinerChain; 103 mEvents = new ArrayList<Event>(source.mEvents); 104 mTypedWord = new StringBuilder(source.mTypedWord); 105 mInputPointers.copy(source.mInputPointers); 106 mCapsCount = source.mCapsCount; 107 mDigitsCount = source.mDigitsCount; 108 mIsFirstCharCapitalized = source.mIsFirstCharCapitalized; 109 mCapitalizedMode = source.mCapitalizedMode; 110 mTrailingSingleQuotesCount = source.mTrailingSingleQuotesCount; 111 mIsResumed = source.mIsResumed; 112 mIsBatchMode = source.mIsBatchMode; 113 mCursorPositionWithinWord = source.mCursorPositionWithinWord; 114 mRejectedBatchModeSuggestion = source.mRejectedBatchModeSuggestion; 115 mPreviousWordForSuggestion = source.mPreviousWordForSuggestion; 116 refreshSize(); 117 } 118 119 /** 120 * Clear out the keys registered so far. 121 */ 122 public void reset() { 123 mCombinerChain.reset(); 124 mTypedWord.setLength(0); 125 mEvents.clear(); 126 mAutoCorrection = null; 127 mCapsCount = 0; 128 mDigitsCount = 0; 129 mIsFirstCharCapitalized = false; 130 mTrailingSingleQuotesCount = 0; 131 mIsResumed = false; 132 mIsBatchMode = false; 133 mCursorPositionWithinWord = 0; 134 mRejectedBatchModeSuggestion = null; 135 mPreviousWordForSuggestion = null; 136 refreshSize(); 137 } 138 139 private final void refreshSize() { 140 mCodePointSize = mTypedWord.codePointCount(0, mTypedWord.length()); 141 } 142 143 /** 144 * Number of keystrokes in the composing word. 145 * @return the number of keystrokes 146 */ 147 public final int size() { 148 return mCodePointSize; 149 } 150 151 public final boolean isComposingWord() { 152 return size() > 0; 153 } 154 155 public InputPointers getInputPointers() { 156 return mInputPointers; 157 } 158 159 private static boolean isFirstCharCapitalized(final int index, final int codePoint, 160 final boolean previous) { 161 if (index == 0) return Character.isUpperCase(codePoint); 162 return previous && !Character.isUpperCase(codePoint); 163 } 164 165 /** 166 * Add a new event for a key stroke, with the pressed key's code point with the touch point 167 * coordinates. 168 */ 169 public void add(final Event event) { 170 final int primaryCode = event.mCodePoint; 171 final int keyX = event.mX; 172 final int keyY = event.mY; 173 final int newIndex = size(); 174 mCombinerChain.processEvent(mEvents, event); 175 // TODO: remove mTypedWord and compute it dynamically when necessary. We also need to 176 // make the views of the composing word a SpannableString. 177 mTypedWord.replace(0, mTypedWord.length(), 178 mCombinerChain.getComposingWordWithCombiningFeedback().toString()); 179 mEvents.add(event); 180 refreshSize(); 181 mCursorPositionWithinWord = mCodePointSize; 182 if (newIndex < MAX_WORD_LENGTH) { 183 // In the batch input mode, the {@code mInputPointers} holds batch input points and 184 // shouldn't be overridden by the "typed key" coordinates 185 // (See {@link #setBatchInputWord}). 186 if (!mIsBatchMode) { 187 // TODO: Set correct pointer id and time 188 mInputPointers.addPointerAt(newIndex, keyX, keyY, 0, 0); 189 } 190 } 191 mIsFirstCharCapitalized = isFirstCharCapitalized( 192 newIndex, primaryCode, mIsFirstCharCapitalized); 193 if (Character.isUpperCase(primaryCode)) mCapsCount++; 194 if (Character.isDigit(primaryCode)) mDigitsCount++; 195 if (Constants.CODE_SINGLE_QUOTE == primaryCode) { 196 ++mTrailingSingleQuotesCount; 197 } else { 198 mTrailingSingleQuotesCount = 0; 199 } 200 mAutoCorrection = null; 201 } 202 203 public void setCursorPositionWithinWord(final int posWithinWord) { 204 mCursorPositionWithinWord = posWithinWord; 205 // TODO: compute where that puts us inside the events 206 } 207 208 public boolean isCursorFrontOrMiddleOfComposingWord() { 209 if (DBG && mCursorPositionWithinWord > mCodePointSize) { 210 throw new RuntimeException("Wrong cursor position : " + mCursorPositionWithinWord 211 + "in a word of size " + mCodePointSize); 212 } 213 return mCursorPositionWithinWord != mCodePointSize; 214 } 215 216 /** 217 * When the cursor is moved by the user, we need to update its position. 218 * If it falls inside the currently composing word, we don't reset the composition, and 219 * only update the cursor position. 220 * 221 * @param expectedMoveAmount How many java chars to move the cursor. Negative values move 222 * the cursor backward, positive values move the cursor forward. 223 * @return true if the cursor is still inside the composing word, false otherwise. 224 */ 225 public boolean moveCursorByAndReturnIfInsideComposingWord(final int expectedMoveAmount) { 226 // TODO: should uncommit the composing feedback 227 mCombinerChain.reset(); 228 int actualMoveAmountWithinWord = 0; 229 int cursorPos = mCursorPositionWithinWord; 230 final int[] codePoints = StringUtils.toCodePointArray(mTypedWord.toString()); 231 if (expectedMoveAmount >= 0) { 232 // Moving the cursor forward for the expected amount or until the end of the word has 233 // been reached, whichever comes first. 234 while (actualMoveAmountWithinWord < expectedMoveAmount && cursorPos < mCodePointSize) { 235 actualMoveAmountWithinWord += Character.charCount(codePoints[cursorPos]); 236 ++cursorPos; 237 } 238 } else { 239 // Moving the cursor backward for the expected amount or until the start of the word 240 // has been reached, whichever comes first. 241 while (actualMoveAmountWithinWord > expectedMoveAmount && cursorPos > 0) { 242 --cursorPos; 243 actualMoveAmountWithinWord -= Character.charCount(codePoints[cursorPos]); 244 } 245 } 246 // If the actual and expected amounts differ, we crossed the start or the end of the word 247 // so the result would not be inside the composing word. 248 if (actualMoveAmountWithinWord != expectedMoveAmount) return false; 249 mCursorPositionWithinWord = cursorPos; 250 return true; 251 } 252 253 public void setBatchInputPointers(final InputPointers batchPointers) { 254 mInputPointers.set(batchPointers); 255 mIsBatchMode = true; 256 } 257 258 public void setBatchInputWord(final String word) { 259 reset(); 260 mIsBatchMode = true; 261 final int length = word.length(); 262 for (int i = 0; i < length; i = Character.offsetByCodePoints(word, i, 1)) { 263 final int codePoint = Character.codePointAt(word, i); 264 // We don't want to override the batch input points that are held in mInputPointers 265 // (See {@link #add(int,int,int)}). 266 add(Event.createEventForCodePointFromUnknownSource(codePoint)); 267 } 268 } 269 270 /** 271 * Set the currently composing word to the one passed as an argument. 272 * This will register NOT_A_COORDINATE for X and Ys, and use the passed keyboard for proximity. 273 * @param codePoints the code points to set as the composing word. 274 * @param coordinates the x, y coordinates of the key in the CoordinateUtils format 275 * @param previousWord the previous word, to use as context for suggestions. Can be null if 276 * the context is nil (typically, at start of text). 277 */ 278 public void setComposingWord(final int[] codePoints, final int[] coordinates, 279 final CharSequence previousWord) { 280 reset(); 281 final int length = codePoints.length; 282 for (int i = 0; i < length; ++i) { 283 add(Event.createEventForCodePointFromAlreadyTypedText(codePoints[i], 284 CoordinateUtils.xFromArray(coordinates, i), 285 CoordinateUtils.yFromArray(coordinates, i))); 286 } 287 mIsResumed = true; 288 mPreviousWordForSuggestion = null == previousWord ? null : previousWord.toString(); 289 } 290 291 /** 292 * Delete the last composing unit as a result of hitting backspace. 293 */ 294 public void deleteLast(final Event event) { 295 mCombinerChain.processEvent(mEvents, event); 296 mTypedWord.replace(0, mTypedWord.length(), 297 mCombinerChain.getComposingWordWithCombiningFeedback().toString()); 298 mEvents.add(event); 299 refreshSize(); 300 // We may have deleted the last one. 301 if (0 == size()) { 302 mIsFirstCharCapitalized = false; 303 } 304 if (mTrailingSingleQuotesCount > 0) { 305 --mTrailingSingleQuotesCount; 306 } else { 307 int i = mTypedWord.length(); 308 while (i > 0) { 309 i = mTypedWord.offsetByCodePoints(i, -1); 310 if (Constants.CODE_SINGLE_QUOTE != mTypedWord.codePointAt(i)) break; 311 ++mTrailingSingleQuotesCount; 312 } 313 } 314 mCursorPositionWithinWord = mCodePointSize; 315 mAutoCorrection = null; 316 } 317 318 /** 319 * Returns the word as it was typed, without any correction applied. 320 * @return the word that was typed so far. Never returns null. 321 */ 322 public String getTypedWord() { 323 return mTypedWord.toString(); 324 } 325 326 public String getPreviousWordForSuggestion() { 327 return mPreviousWordForSuggestion; 328 } 329 330 /** 331 * Whether or not the user typed a capital letter as the first letter in the word 332 * @return capitalization preference 333 */ 334 public boolean isFirstCharCapitalized() { 335 return mIsFirstCharCapitalized; 336 } 337 338 public int trailingSingleQuotesCount() { 339 return mTrailingSingleQuotesCount; 340 } 341 342 /** 343 * Whether or not all of the user typed chars are upper case 344 * @return true if all user typed chars are upper case, false otherwise 345 */ 346 public boolean isAllUpperCase() { 347 if (size() <= 1) { 348 return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED 349 || mCapitalizedMode == CAPS_MODE_MANUAL_SHIFT_LOCKED; 350 } else { 351 return mCapsCount == size(); 352 } 353 } 354 355 public boolean wasShiftedNoLock() { 356 return mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED 357 || mCapitalizedMode == CAPS_MODE_MANUAL_SHIFTED; 358 } 359 360 /** 361 * Returns true if more than one character is upper case, otherwise returns false. 362 */ 363 public boolean isMostlyCaps() { 364 return mCapsCount > 1; 365 } 366 367 /** 368 * Returns true if we have digits in the composing word. 369 */ 370 public boolean hasDigits() { 371 return mDigitsCount > 0; 372 } 373 374 /** 375 * Saves the caps mode and the previous word at the start of composing. 376 * 377 * WordComposer needs to know about the caps mode for several reasons. The first is, we need 378 * to know after the fact what the reason was, to register the correct form into the user 379 * history dictionary: if the word was automatically capitalized, we should insert it in 380 * all-lower case but if it's a manual pressing of shift, then it should be inserted as is. 381 * Also, batch input needs to know about the current caps mode to display correctly 382 * capitalized suggestions. 383 * @param mode the mode at the time of start 384 * @param previousWord the previous word as context for suggestions. May be null if none. 385 */ 386 public void setCapitalizedModeAndPreviousWordAtStartComposingTime(final int mode, 387 final CharSequence previousWord) { 388 mCapitalizedMode = mode; 389 mPreviousWordForSuggestion = null == previousWord ? null : previousWord.toString(); 390 } 391 392 /** 393 * Returns whether the word was automatically capitalized. 394 * @return whether the word was automatically capitalized 395 */ 396 public boolean wasAutoCapitalized() { 397 return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED 398 || mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED; 399 } 400 401 /** 402 * Sets the auto-correction for this word. 403 */ 404 public void setAutoCorrection(final String correction) { 405 mAutoCorrection = correction; 406 } 407 408 /** 409 * @return the auto-correction for this word, or null if none. 410 */ 411 public String getAutoCorrectionOrNull() { 412 return mAutoCorrection; 413 } 414 415 /** 416 * @return whether we started composing this word by resuming suggestion on an existing string 417 */ 418 public boolean isResumed() { 419 return mIsResumed; 420 } 421 422 // `type' should be one of the LastComposedWord.COMMIT_TYPE_* constants above. 423 // committedWord should contain suggestion spans if applicable. 424 public LastComposedWord commitWord(final int type, final CharSequence committedWord, 425 final String separatorString, final String prevWord) { 426 // Note: currently, we come here whenever we commit a word. If it's a MANUAL_PICK 427 // or a DECIDED_WORD we may cancel the commit later; otherwise, we should deactivate 428 // the last composed word to ensure this does not happen. 429 final LastComposedWord lastComposedWord = new LastComposedWord(mEvents, 430 mInputPointers, mTypedWord.toString(), committedWord, separatorString, 431 prevWord, mCapitalizedMode); 432 mInputPointers.reset(); 433 if (type != LastComposedWord.COMMIT_TYPE_DECIDED_WORD 434 && type != LastComposedWord.COMMIT_TYPE_MANUAL_PICK) { 435 lastComposedWord.deactivate(); 436 } 437 mCapsCount = 0; 438 mDigitsCount = 0; 439 mIsBatchMode = false; 440 mPreviousWordForSuggestion = committedWord.toString(); 441 mTypedWord.setLength(0); 442 mCombinerChain.reset(); 443 mEvents.clear(); 444 mCodePointSize = 0; 445 mTrailingSingleQuotesCount = 0; 446 mIsFirstCharCapitalized = false; 447 mCapitalizedMode = CAPS_MODE_OFF; 448 refreshSize(); 449 mAutoCorrection = null; 450 mCursorPositionWithinWord = 0; 451 mIsResumed = false; 452 mRejectedBatchModeSuggestion = null; 453 return lastComposedWord; 454 } 455 456 // Call this when the recorded previous word should be discarded. This is typically called 457 // when the user inputs a separator that's not whitespace (including the case of the 458 // double-space-to-period feature). 459 public void discardPreviousWordForSuggestion() { 460 mPreviousWordForSuggestion = null; 461 } 462 463 public void resumeSuggestionOnLastComposedWord(final LastComposedWord lastComposedWord, 464 final String previousWord) { 465 mEvents.clear(); 466 Collections.copy(mEvents, lastComposedWord.mEvents); 467 mInputPointers.set(lastComposedWord.mInputPointers); 468 mTypedWord.setLength(0); 469 mCombinerChain.reset(); 470 mTypedWord.append(lastComposedWord.mTypedWord); 471 refreshSize(); 472 mCapitalizedMode = lastComposedWord.mCapitalizedMode; 473 mAutoCorrection = null; // This will be filled by the next call to updateSuggestion. 474 mCursorPositionWithinWord = mCodePointSize; 475 mRejectedBatchModeSuggestion = null; 476 mIsResumed = true; 477 mPreviousWordForSuggestion = previousWord; 478 } 479 480 public boolean isBatchMode() { 481 return mIsBatchMode; 482 } 483 484 public void setRejectedBatchModeSuggestion(final String rejectedSuggestion) { 485 mRejectedBatchModeSuggestion = rejectedSuggestion; 486 } 487 488 public String getRejectedBatchModeSuggestion() { 489 return mRejectedBatchModeSuggestion; 490 } 491} 492