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