TextDecorator.java revision 8c42bf54af9afe44eade9f0c36cfd2136d20e2f6
1/*
2 * Copyright (C) 2014 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.keyboard;
18
19import android.graphics.Matrix;
20import android.graphics.PointF;
21import android.graphics.RectF;
22import android.inputmethodservice.InputMethodService;
23import android.os.Message;
24import android.text.TextUtils;
25import android.util.Log;
26import android.view.View;
27import android.view.inputmethod.CursorAnchorInfo;
28
29import com.android.inputmethod.annotations.UsedForTesting;
30import com.android.inputmethod.compat.CursorAnchorInfoCompatWrapper;
31import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
32import com.android.inputmethod.latin.utils.LeakGuardHandlerWrapper;
33
34import javax.annotation.Nonnull;
35
36/**
37 * A controller class of commit/add-to-dictionary indicator (a.k.a. TextDecorator). This class
38 * is designed to be independent of UI subsystems such as {@link View}. All the UI related
39 * operations are delegated to {@link TextDecoratorUi} via {@link TextDecoratorUiOperator}.
40 */
41public class TextDecorator {
42    private static final String TAG = TextDecorator.class.getSimpleName();
43    private static final boolean DEBUG = false;
44
45    private static final int MODE_NONE = 0;
46    private static final int MODE_COMMIT = 1;
47    private static final int MODE_ADD_TO_DICTIONARY = 2;
48
49    private int mMode = MODE_NONE;
50
51    private final PointF mLocalOrigin = new PointF();
52    private final RectF mRelativeIndicatorBounds = new RectF();
53    private final RectF mRelativeComposingTextBounds = new RectF();
54
55    private boolean mIsFullScreenMode = false;
56    private SuggestedWordInfo mWaitingWord = null;
57    private CursorAnchorInfoCompatWrapper mCursorAnchorInfoWrapper = null;
58
59    @Nonnull
60    private final Listener mListener;
61
62    @Nonnull
63    private TextDecoratorUiOperator mUiOperator = EMPTY_UI_OPERATOR;
64
65    public interface Listener {
66        /**
67         * Called when the user clicks the composing text to commit.
68         * @param wordInfo the suggested word which the user clicked on.
69         */
70        void onClickComposingTextToCommit(final SuggestedWordInfo wordInfo);
71
72        /**
73         * Called when the user clicks the composing text to add the word into the dictionary.
74         * @param wordInfo the suggested word which the user clicked on.
75         */
76        void onClickComposingTextToAddToDictionary(final SuggestedWordInfo wordInfo);
77    }
78
79    public TextDecorator(final Listener listener) {
80        mListener = (listener != null) ? listener : EMPTY_LISTENER;
81    }
82
83    /**
84     * Sets the UI operator for {@link TextDecorator}. Any user visible operations will be
85     * delegated to the associated UI operator.
86     * @param uiOperator the UI operator to be associated.
87     */
88    public void setUiOperator(final TextDecoratorUiOperator uiOperator) {
89        mUiOperator.disposeUi();
90        mUiOperator = uiOperator;
91        mUiOperator.setOnClickListener(getOnClickHandler());
92    }
93
94    private final Runnable mDefaultOnClickHandler = new Runnable() {
95        @Override
96        public void run() {
97            onClickIndicator();
98        }
99    };
100
101    @UsedForTesting
102    final Runnable getOnClickHandler() {
103        return mDefaultOnClickHandler;
104    }
105
106    /**
107     * Shows the "Commit" indicator and associates it with the given suggested word.
108     *
109     * <p>The effect of {@link #showCommitIndicator(SuggestedWordInfo)} and
110     * {@link #showAddToDictionaryIndicator(SuggestedWordInfo)} are exclusive to each other. Call
111     * {@link #reset()} to hide the indicator.</p>
112     *
113     * @param wordInfo the suggested word which should be associated with the indicator. This object
114     * will be passed back in {@link Listener#onClickComposingTextToCommit(SuggestedWordInfo)}
115     */
116    public void showCommitIndicator(final SuggestedWordInfo wordInfo) {
117        if (mMode == MODE_COMMIT && wordInfo != null &&
118                TextUtils.equals(mWaitingWord.mWord, wordInfo.mWord)) {
119            // Skip layout for better performance.
120            return;
121        }
122        mWaitingWord = wordInfo;
123        mMode = MODE_COMMIT;
124        layoutLater();
125    }
126
127    /**
128     * Shows the "Add to dictionary" indicator and associates it with associating the given
129     * suggested word.
130     *
131     * <p>The effect of {@link #showCommitIndicator(SuggestedWordInfo)} and
132     * {@link #showAddToDictionaryIndicator(SuggestedWordInfo)} are exclusive to each other. Call
133     * {@link #reset()} to hide the indicator.</p>
134     *
135     * @param wordInfo the suggested word which should be associated with the indicator. This object
136     * will be passed back in
137     * {@link Listener#onClickComposingTextToAddToDictionary(SuggestedWordInfo)}.
138     */
139    public void showAddToDictionaryIndicator(final SuggestedWordInfo wordInfo) {
140        if (mMode == MODE_ADD_TO_DICTIONARY && wordInfo != null &&
141                TextUtils.equals(mWaitingWord.mWord, wordInfo.mWord)) {
142            // Skip layout for better performance.
143            return;
144        }
145        mWaitingWord = wordInfo;
146        mMode = MODE_ADD_TO_DICTIONARY;
147        layoutLater();
148        return;
149    }
150
151    /**
152     * Must be called when the input method is about changing to for from the full screen mode.
153     * @param fullScreenMode {@code true} if the input method is entering the full screen mode.
154     * {@code false} is the input method is finishing the full screen mode.
155     */
156    public void notifyFullScreenMode(final boolean fullScreenMode) {
157        final boolean currentFullScreenMode = mIsFullScreenMode;
158        if (!currentFullScreenMode && fullScreenMode) {
159            // Currently full screen mode is not supported.
160            // TODO: Support full screen mode.
161            mUiOperator.hideUi();
162        }
163        mIsFullScreenMode = fullScreenMode;
164    }
165
166    /**
167     * Resets previous requests and makes indicator invisible.
168     */
169    public void reset() {
170        mWaitingWord = null;
171        mMode = MODE_NONE;
172        mLocalOrigin.set(0.0f, 0.0f);
173        mRelativeIndicatorBounds.set(0.0f, 0.0f, 0.0f, 0.0f);
174        mRelativeComposingTextBounds.set(0.0f, 0.0f, 0.0f, 0.0f);
175        cancelLayoutInternalExpectedly("Resetting internal state.");
176    }
177
178    /**
179     * Must be called when the {@link InputMethodService#onUpdateCursorAnchorInfo()} is called.
180     *
181     * <p>CAVEAT: Currently the input method author is responsible for ignoring
182     * {@link InputMethodService#onUpdateCursorAnchorInfo()} called in full screen mode.</p>
183     * @param info the compatibility wrapper object for the received {@link CursorAnchorInfo}.
184     */
185    public void onUpdateCursorAnchorInfo(final CursorAnchorInfoCompatWrapper info) {
186        if (mIsFullScreenMode) {
187            // TODO: Consider to call InputConnection#requestCursorAnchorInfo to disable the
188            // event callback to suppress unnecessary event callbacks.
189            return;
190        }
191        mCursorAnchorInfoWrapper = info;
192        // Do not use layoutLater() to minimize the latency.
193        layoutImmediately();
194    }
195
196    /**
197     * Hides indicator if the new composing text doesn't match the expected one.
198     *
199     * <p>Calling this method is optional but recommended whenever the new composition is passed to
200     * the application. The motivation of this method is to reduce the UI latency. With this method,
201     * we can hide the indicator without waiting the arrival of the
202     * {@link InputMethodService#onUpdateCursorAnchorInfo(CursorAnchorInfo)} callback, assuming that
203     * the application accepts the new composing text without any modification. Even if this
204     * assumption is false, the indicator will be shown again when
205     * {@link InputMethodService#onUpdateCursorAnchorInfo(CursorAnchorInfo)} is actually received.
206     * </p>
207     *
208     * @param newComposingText the new composing text that is being passed to the application.
209     */
210    public void hideIndicatorIfNecessary(final CharSequence newComposingText) {
211        if (mMode != MODE_COMMIT && mMode != MODE_ADD_TO_DICTIONARY) {
212            return;
213        }
214        if (!TextUtils.equals(newComposingText, mWaitingWord.mWord)) {
215            mUiOperator.hideUi();
216        }
217    }
218
219    private void cancelLayoutInternalUnexpectedly(final String message) {
220        mUiOperator.hideUi();
221        Log.d(TAG, message);
222    }
223
224    private void cancelLayoutInternalExpectedly(final String message) {
225        mUiOperator.hideUi();
226        if (DEBUG) {
227            Log.d(TAG, message);
228        }
229    }
230
231    private void layoutLater() {
232        mLayoutInvalidator.invalidateLayout();
233    }
234
235
236    private void layoutImmediately() {
237        // Clear pending layout requests.
238        mLayoutInvalidator.cancelInvalidateLayout();
239        layoutMain();
240    }
241
242    private void layoutMain() {
243        if (mIsFullScreenMode) {
244            cancelLayoutInternalUnexpectedly("Full screen mode isn't yet supported.");
245            return;
246        }
247
248        if (mMode != MODE_COMMIT && mMode != MODE_ADD_TO_DICTIONARY) {
249            if (mMode == MODE_NONE) {
250                cancelLayoutInternalExpectedly("Not ready for layouting.");
251            } else {
252                cancelLayoutInternalUnexpectedly("Unknown mMode=" + mMode);
253            }
254            return;
255        }
256
257        final CursorAnchorInfoCompatWrapper info = mCursorAnchorInfoWrapper;
258
259        if (info == null) {
260            cancelLayoutInternalExpectedly("CursorAnchorInfo isn't available.");
261            return;
262        }
263
264        final Matrix matrix = info.getMatrix();
265        if (matrix == null) {
266            cancelLayoutInternalUnexpectedly("Matrix is null");
267        }
268
269        final CharSequence composingText = info.getComposingText();
270        if (mMode == MODE_COMMIT) {
271            if (composingText == null) {
272                cancelLayoutInternalExpectedly("composingText is null.");
273                return;
274            }
275            final int composingTextStart = info.getComposingTextStart();
276            final int lastCharRectIndex = composingTextStart + composingText.length() - 1;
277            final RectF lastCharRect = info.getCharacterRect(lastCharRectIndex);
278            final int lastCharRectFlag = info.getCharacterRectFlags(lastCharRectIndex);
279            final boolean hasInvisibleRegionInLastCharRect =
280                    (lastCharRectFlag & CursorAnchorInfoCompatWrapper.FLAG_HAS_INVISIBLE_REGION)
281                            != 0;
282            if (lastCharRect == null || matrix == null || hasInvisibleRegionInLastCharRect) {
283                mUiOperator.hideUi();
284                return;
285            }
286            final RectF segmentStartCharRect = new RectF(lastCharRect);
287            for (int i = composingText.length() - 2; i >= 0; --i) {
288                final RectF charRect = info.getCharacterRect(composingTextStart + i);
289                if (charRect == null) {
290                    break;
291                }
292                if (charRect.top != segmentStartCharRect.top) {
293                    break;
294                }
295                if (charRect.bottom != segmentStartCharRect.bottom) {
296                    break;
297                }
298                segmentStartCharRect.set(charRect);
299            }
300
301            mLocalOrigin.set(lastCharRect.right, lastCharRect.top);
302            mRelativeIndicatorBounds.set(lastCharRect.right, lastCharRect.top,
303                    lastCharRect.right + lastCharRect.height(), lastCharRect.bottom);
304            mRelativeIndicatorBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
305
306            mRelativeIndicatorBounds.set(lastCharRect.right, lastCharRect.top,
307                    lastCharRect.right + lastCharRect.height(), lastCharRect.bottom);
308            mRelativeIndicatorBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
309
310            mRelativeComposingTextBounds.set(segmentStartCharRect.left, segmentStartCharRect.top,
311                    segmentStartCharRect.right, segmentStartCharRect.bottom);
312            mRelativeComposingTextBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
313
314            if (mWaitingWord == null) {
315                cancelLayoutInternalExpectedly("mWaitingText is null.");
316                return;
317            }
318            if (TextUtils.isEmpty(mWaitingWord.mWord)) {
319                cancelLayoutInternalExpectedly("mWaitingText.mWord is empty.");
320                return;
321            }
322            if (!TextUtils.equals(composingText, mWaitingWord.mWord)) {
323                // This is indeed an expected situation because of the asynchronous nature of
324                // input method framework in Android. Note that composingText is notified from the
325                // application, while mWaitingWord.mWord is obtained directly from the InputLogic.
326                cancelLayoutInternalExpectedly(
327                        "Composing text doesn't match the one we are waiting for.");
328                return;
329            }
330        } else {
331            if (!TextUtils.isEmpty(composingText)) {
332                // This is an unexpected case.
333                // TODO: Document this.
334                mUiOperator.hideUi();
335                return;
336            }
337            // In MODE_ADD_TO_DICTIONARY, we cannot retrieve the character position at all because
338            // of the lack of composing text. We will use the insertion marker position instead.
339            if ((info.getInsertionMarkerFlags() &
340                    CursorAnchorInfoCompatWrapper.FLAG_HAS_INVISIBLE_REGION) != 0) {
341                mUiOperator.hideUi();
342                return;
343            }
344            final float insertionMarkerHolizontal = info.getInsertionMarkerHorizontal();
345            final float insertionMarkerTop = info.getInsertionMarkerTop();
346            mLocalOrigin.set(insertionMarkerHolizontal, insertionMarkerTop);
347        }
348
349        final RectF indicatorBounds = new RectF(mRelativeIndicatorBounds);
350        final RectF composingTextBounds = new RectF(mRelativeComposingTextBounds);
351        indicatorBounds.offset(mLocalOrigin.x, mLocalOrigin.y);
352        composingTextBounds.offset(mLocalOrigin.x, mLocalOrigin.y);
353        mUiOperator.layoutUi(mMode == MODE_COMMIT, matrix, indicatorBounds, composingTextBounds);
354    }
355
356    private void onClickIndicator() {
357        if (mWaitingWord == null || TextUtils.isEmpty(mWaitingWord.mWord)) {
358            return;
359        }
360        switch (mMode) {
361            case MODE_COMMIT:
362                mListener.onClickComposingTextToCommit(mWaitingWord);
363                break;
364            case MODE_ADD_TO_DICTIONARY:
365                mListener.onClickComposingTextToAddToDictionary(mWaitingWord);
366                break;
367        }
368    }
369
370    private final LayoutInvalidator mLayoutInvalidator = new LayoutInvalidator(this);
371
372    /**
373     * Used for managing pending layout tasks for {@link TextDecorator#layoutLater()}.
374     */
375    private static final class LayoutInvalidator {
376        private final HandlerImpl mHandler;
377        public LayoutInvalidator(final TextDecorator ownerInstance) {
378            mHandler = new HandlerImpl(ownerInstance);
379        }
380
381        private static final int MSG_LAYOUT = 0;
382
383        private static final class HandlerImpl
384                extends LeakGuardHandlerWrapper<TextDecorator> {
385            public HandlerImpl(final TextDecorator ownerInstance) {
386                super(ownerInstance);
387            }
388
389            @Override
390            public void handleMessage(final Message msg) {
391                final TextDecorator owner = getOwnerInstance();
392                if (owner == null) {
393                    return;
394                }
395                switch (msg.what) {
396                    case MSG_LAYOUT:
397                        owner.layoutMain();
398                        break;
399                }
400            }
401        }
402
403        /**
404         * Puts a layout task into the scheduler. Does nothing if one or more layout tasks are
405         * already scheduled.
406         */
407        public void invalidateLayout() {
408            if (!mHandler.hasMessages(MSG_LAYOUT)) {
409                mHandler.obtainMessage(MSG_LAYOUT).sendToTarget();
410            }
411        }
412
413        /**
414         * Clears the pending layout tasks.
415         */
416        public void cancelInvalidateLayout() {
417            mHandler.removeMessages(MSG_LAYOUT);
418        }
419    }
420
421    private final static Listener EMPTY_LISTENER = new Listener() {
422        @Override
423        public void onClickComposingTextToCommit(SuggestedWordInfo wordInfo) {
424        }
425        @Override
426        public void onClickComposingTextToAddToDictionary(SuggestedWordInfo wordInfo) {
427        }
428    };
429
430    private final static TextDecoratorUiOperator EMPTY_UI_OPERATOR = new TextDecoratorUiOperator() {
431        @Override
432        public void disposeUi() {
433        }
434        @Override
435        public void hideUi() {
436        }
437        @Override
438        public void setOnClickListener(Runnable listener) {
439        }
440        @Override
441        public void layoutUi(boolean isCommitMode, Matrix matrix, RectF indicatorBounds,
442                RectF composingTextBounds) {
443        }
444    };
445}
446