TextDecorator.java revision bea17c49ec23bf0f646cb548445c7756aa50d233
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            hideIndicator();
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    private void hideIndicator() {
197        mUiOperator.hideUi();
198    }
199
200    private void cancelLayoutInternalUnexpectedly(final String message) {
201        hideIndicator();
202        Log.d(TAG, message);
203    }
204
205    private void cancelLayoutInternalExpectedly(final String message) {
206        hideIndicator();
207        if (DEBUG) {
208            Log.d(TAG, message);
209        }
210    }
211
212    private void layoutLater() {
213        mLayoutInvalidator.invalidateLayout();
214    }
215
216
217    private void layoutImmediately() {
218        // Clear pending layout requests.
219        mLayoutInvalidator.cancelInvalidateLayout();
220        layoutMain();
221    }
222
223    private void layoutMain() {
224        if (mIsFullScreenMode) {
225            cancelLayoutInternalUnexpectedly("Full screen mode isn't yet supported.");
226            return;
227        }
228
229        if (mMode != MODE_COMMIT && mMode != MODE_ADD_TO_DICTIONARY) {
230            if (mMode == MODE_NONE) {
231                cancelLayoutInternalExpectedly("Not ready for layouting.");
232            } else {
233                cancelLayoutInternalUnexpectedly("Unknown mMode=" + mMode);
234            }
235            return;
236        }
237
238        final CursorAnchorInfoCompatWrapper info = mCursorAnchorInfoWrapper;
239
240        if (info == null) {
241            cancelLayoutInternalExpectedly("CursorAnchorInfo isn't available.");
242            return;
243        }
244
245        final Matrix matrix = info.getMatrix();
246        if (matrix == null) {
247            cancelLayoutInternalUnexpectedly("Matrix is null");
248        }
249
250        final CharSequence composingText = info.getComposingText();
251        if (mMode == MODE_COMMIT) {
252            if (composingText == null) {
253                cancelLayoutInternalExpectedly("composingText is null.");
254                return;
255            }
256            final int composingTextStart = info.getComposingTextStart();
257            final int lastCharRectIndex = composingTextStart + composingText.length() - 1;
258            final RectF lastCharRect = info.getCharacterRect(lastCharRectIndex);
259            final int lastCharRectFlag = info.getCharacterRectFlags(lastCharRectIndex);
260            final int lastCharRectType =
261                    lastCharRectFlag & CursorAnchorInfoCompatWrapper.CHARACTER_RECT_TYPE_MASK;
262            if (lastCharRect == null || matrix == null || lastCharRectType !=
263                    CursorAnchorInfoCompatWrapper.CHARACTER_RECT_TYPE_FULLY_VISIBLE) {
264                hideIndicator();
265                return;
266            }
267            final RectF segmentStartCharRect = new RectF(lastCharRect);
268            for (int i = composingText.length() - 2; i >= 0; --i) {
269                final RectF charRect = info.getCharacterRect(composingTextStart + i);
270                if (charRect == null) {
271                    break;
272                }
273                if (charRect.top != segmentStartCharRect.top) {
274                    break;
275                }
276                if (charRect.bottom != segmentStartCharRect.bottom) {
277                    break;
278                }
279                segmentStartCharRect.set(charRect);
280            }
281
282            mLocalOrigin.set(lastCharRect.right, lastCharRect.top);
283            mRelativeIndicatorBounds.set(lastCharRect.right, lastCharRect.top,
284                    lastCharRect.right + lastCharRect.height(), lastCharRect.bottom);
285            mRelativeIndicatorBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
286
287            mRelativeIndicatorBounds.set(lastCharRect.right, lastCharRect.top,
288                    lastCharRect.right + lastCharRect.height(), lastCharRect.bottom);
289            mRelativeIndicatorBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
290
291            mRelativeComposingTextBounds.set(segmentStartCharRect.left, segmentStartCharRect.top,
292                    segmentStartCharRect.right, segmentStartCharRect.bottom);
293            mRelativeComposingTextBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
294
295            if (mWaitingWord == null) {
296                cancelLayoutInternalExpectedly("mWaitingText is null.");
297                return;
298            }
299            if (TextUtils.isEmpty(mWaitingWord.mWord)) {
300                cancelLayoutInternalExpectedly("mWaitingText.mWord is empty.");
301                return;
302            }
303            if (!TextUtils.equals(composingText, mWaitingWord.mWord)) {
304                // This is indeed an expected situation because of the asynchronous nature of
305                // input method framework in Android. Note that composingText is notified from the
306                // application, while mWaitingWord.mWord is obtained directly from the InputLogic.
307                cancelLayoutInternalExpectedly(
308                        "Composing text doesn't match the one we are waiting for.");
309                return;
310            }
311        } else {
312            if (!TextUtils.isEmpty(composingText)) {
313                // This is an unexpected case.
314                // TODO: Document this.
315                hideIndicator();
316                return;
317            }
318            // In MODE_ADD_TO_DICTIONARY, we cannot retrieve the character position at all because
319            // of the lack of composing text. We will use the insertion marker position instead.
320            if (info.isInsertionMarkerClipped()) {
321                hideIndicator();
322                return;
323            }
324            final float insertionMarkerHolizontal = info.getInsertionMarkerHorizontal();
325            final float insertionMarkerTop = info.getInsertionMarkerTop();
326            mLocalOrigin.set(insertionMarkerHolizontal, insertionMarkerTop);
327        }
328
329        final RectF indicatorBounds = new RectF(mRelativeIndicatorBounds);
330        final RectF composingTextBounds = new RectF(mRelativeComposingTextBounds);
331        indicatorBounds.offset(mLocalOrigin.x, mLocalOrigin.y);
332        composingTextBounds.offset(mLocalOrigin.x, mLocalOrigin.y);
333        mUiOperator.layoutUi(mMode == MODE_COMMIT, matrix, indicatorBounds, composingTextBounds);
334    }
335
336    private void onClickIndicator() {
337        if (mWaitingWord == null || TextUtils.isEmpty(mWaitingWord.mWord)) {
338            return;
339        }
340        switch (mMode) {
341            case MODE_COMMIT:
342                mListener.onClickComposingTextToCommit(mWaitingWord);
343                break;
344            case MODE_ADD_TO_DICTIONARY:
345                mListener.onClickComposingTextToAddToDictionary(mWaitingWord);
346                break;
347        }
348    }
349
350    private final LayoutInvalidator mLayoutInvalidator = new LayoutInvalidator(this);
351
352    /**
353     * Used for managing pending layout tasks for {@link TextDecorator#layoutLater()}.
354     */
355    private static final class LayoutInvalidator {
356        private final HandlerImpl mHandler;
357        public LayoutInvalidator(final TextDecorator ownerInstance) {
358            mHandler = new HandlerImpl(ownerInstance);
359        }
360
361        private static final int MSG_LAYOUT = 0;
362
363        private static final class HandlerImpl
364                extends LeakGuardHandlerWrapper<TextDecorator> {
365            public HandlerImpl(final TextDecorator ownerInstance) {
366                super(ownerInstance);
367            }
368
369            @Override
370            public void handleMessage(final Message msg) {
371                final TextDecorator owner = getOwnerInstance();
372                if (owner == null) {
373                    return;
374                }
375                switch (msg.what) {
376                    case MSG_LAYOUT:
377                        owner.layoutMain();
378                        break;
379                }
380            }
381        }
382
383        /**
384         * Puts a layout task into the scheduler. Does nothing if one or more layout tasks are
385         * already scheduled.
386         */
387        public void invalidateLayout() {
388            if (!mHandler.hasMessages(MSG_LAYOUT)) {
389                mHandler.obtainMessage(MSG_LAYOUT).sendToTarget();
390            }
391        }
392
393        /**
394         * Clears the pending layout tasks.
395         */
396        public void cancelInvalidateLayout() {
397            mHandler.removeMessages(MSG_LAYOUT);
398        }
399    }
400
401    private final static Listener EMPTY_LISTENER = new Listener() {
402        @Override
403        public void onClickComposingTextToCommit(SuggestedWordInfo wordInfo) {
404        }
405        @Override
406        public void onClickComposingTextToAddToDictionary(SuggestedWordInfo wordInfo) {
407        }
408    };
409
410    private final static TextDecoratorUiOperator EMPTY_UI_OPERATOR = new TextDecoratorUiOperator() {
411        @Override
412        public void disposeUi() {
413        }
414        @Override
415        public void hideUi() {
416        }
417        @Override
418        public void setOnClickListener(Runnable listener) {
419        }
420        @Override
421        public void layoutUi(boolean isCommitMode, Matrix matrix, RectF indicatorBounds,
422                RectF composingTextBounds) {
423        }
424    };
425}
426