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