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