Introduce commit/add-to-dictionary indicators

This CL introduces commit/add-to-dictionary indicators.

Note that the text is not yet highlighted when the commit
indicator is displayed. It will be addressed in subsequent
CLs.

Change-Id: I7e9b0fcfdc0776a50a1d8cfb41ee0add813317dd
diff --git a/java/res/values/donottranslate-text-decorator.xml b/java/res/values/donottranslate-text-decorator.xml
new file mode 100644
index 0000000..9c39a46
--- /dev/null
+++ b/java/res/values/donottranslate-text-decorator.xml
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+**
+** Copyright 2014, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+**     http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+** See the License for the specific language governing permissions and
+** limitations under the License.
+*/
+-->
+
+<resources>
+    <!-- The delay time in milliseconds from to show the commit indicator -->
+    <integer name="text_decorator_delay_in_milliseconds_to_show_commit_indicator">
+        500
+    </integer>
+
+    <!-- The extra margin in dp around the hit area of the commit/add-to-dictionary indicator -->
+    <integer name="text_decorator_hit_area_margin_in_dp">
+        4
+    </integer>
+
+    <!-- If true, the commit/add-to-text indicator will be suppressed when the word isn't going to
+         trigger auto-correction. -->
+    <bool name="text_decorator_only_for_auto_correction">false</bool>
+
+    <!-- If true, the commit/add-to-text indicator will be suppressed when the word is already in
+         the dictionary. -->
+    <bool name="text_decorator_only_for_out_of_vocabulary">false</bool>
+
+    <!-- Background color to be used to highlight the target text when the commit indicator is
+         visible. -->
+    <color name="text_decorator_commit_indicator_text_highlight_color">
+        #B6E2DE
+    </color>
+
+    <!-- Background color of the commit indicator. -->
+    <color name="text_decorator_commit_indicator_background_color">
+        #48B6AC
+    </color>
+
+    <!-- Foreground color of the commit indicator. -->
+    <color name="text_decorator_commit_indicator_foreground_color">
+        #FFFFFF
+    </color>
+
+    <!-- Viewport size of "text_decorator_commit_indicator_path". -->
+    <integer name="text_decorator_commit_indicator_path_size">
+        480
+    </integer>
+
+    <!-- Coordinates of the closed path to be used to render the commit indicator.
+         The format is:  X[0], Y[0], X[1], Y[1], ..., X[N-1], Y[N-1] -->
+    <integer-array name="text_decorator_commit_indicator_path">
+        <item>180</item>
+        <item>323</item>
+        <item>97</item>
+        <item>240</item>
+        <item>68</item>
+        <item>268</item>
+        <item>180</item>
+        <item>380</item>
+        <item>420</item>
+        <item>140</item>
+        <item>392</item>
+        <item>112</item>
+    </integer-array>
+
+    <!-- Background color to be used to highlight the target text when the add-to-dictionary
+         indicator is visible. -->
+    <color name="text_decorator_add_to_dictionary_indicator_text_highlight_color">
+        #D1E7B7
+    </color>
+
+    <!-- Foreground color of the commit indicator. -->
+    <color name="text_decorator_add_to_dictionary_indicator_background_color">
+        #4EB848
+    </color>
+
+    <!-- Foreground color of the add-to-dictionary indicator. -->
+    <color name="text_decorator_add_to_dictionary_indicator_foreground_color">
+        #FFFFFF
+    </color>
+
+    <!-- Viewport size of "text_decorator_add_to_dictionary_indicator_path". -->
+    <integer name="text_decorator_add_to_dictionary_indicator_path_size">
+        480
+    </integer>
+
+    <!-- Coordinates of the closed path to be used to render the add-to-dictionary indicator.
+         The format is: X[0], Y[0], X[1], Y[1], ..., X[N-1], Y[N-1] -->
+    <integer-array name="text_decorator_add_to_dictionary_indicator_path">
+        <item>380</item>
+        <item>260</item>
+        <item>260</item>
+        <item>260</item>
+        <item>260</item>
+        <item>380</item>
+        <item>220</item>
+        <item>380</item>
+        <item>220</item>
+        <item>260</item>
+        <item>100</item>
+        <item>260</item>
+        <item>100</item>
+        <item>220</item>
+        <item>220</item>
+        <item>220</item>
+        <item>220</item>
+        <item>100</item>
+        <item>260</item>
+        <item>100</item>
+        <item>260</item>
+        <item>220</item>
+        <item>380</item>
+        <item>220</item>
+    </integer-array>
+</resources>
diff --git a/java/src/com/android/inputmethod/keyboard/TextDecorator.java b/java/src/com/android/inputmethod/keyboard/TextDecorator.java
new file mode 100644
index 0000000..0eb8b44
--- /dev/null
+++ b/java/src/com/android/inputmethod/keyboard/TextDecorator.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.keyboard;
+
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.inputmethodservice.InputMethodService;
+import android.os.Message;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+import android.view.inputmethod.CursorAnchorInfo;
+
+import com.android.inputmethod.annotations.UsedForTesting;
+import com.android.inputmethod.compat.CursorAnchorInfoCompatWrapper;
+import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
+import com.android.inputmethod.latin.utils.LeakGuardHandlerWrapper;
+
+import javax.annotation.Nonnull;
+
+/**
+ * A controller class of commit/add-to-dictionary indicator (a.k.a. TextDecorator). This class
+ * is designed to be independent of UI subsystems such as {@link View}. All the UI related
+ * operations are delegated to {@link TextDecoratorUi} via {@link TextDecoratorUiOperator}.
+ */
+public class TextDecorator {
+    private static final String TAG = TextDecorator.class.getSimpleName();
+    private static final boolean DEBUG = false;
+
+    private static final int MODE_NONE = 0;
+    private static final int MODE_COMMIT = 1;
+    private static final int MODE_ADD_TO_DICTIONARY = 2;
+
+    private int mMode = MODE_NONE;
+
+    private final PointF mLocalOrigin = new PointF();
+    private final RectF mRelativeIndicatorBounds = new RectF();
+    private final RectF mRelativeComposingTextBounds = new RectF();
+
+    private boolean mIsFullScreenMode = false;
+    private SuggestedWordInfo mWaitingWord = null;
+    private CursorAnchorInfoCompatWrapper mCursorAnchorInfoWrapper = null;
+
+    @Nonnull
+    private final Listener mListener;
+
+    @Nonnull
+    private TextDecoratorUiOperator mUiOperator = EMPTY_UI_OPERATOR;
+
+    public interface Listener {
+        /**
+         * Called when the user clicks the composing text to commit.
+         * @param wordInfo the suggested word which the user clicked on.
+         */
+        void onClickComposingTextToCommit(final SuggestedWordInfo wordInfo);
+
+        /**
+         * Called when the user clicks the composing text to add the word into the dictionary.
+         * @param wordInfo the suggested word which the user clicked on.
+         */
+        void onClickComposingTextToAddToDictionary(final SuggestedWordInfo wordInfo);
+    }
+
+    public TextDecorator(final Listener listener) {
+        mListener = (listener != null) ? listener : EMPTY_LISTENER;
+    }
+
+    /**
+     * Sets the UI operator for {@link TextDecorator}. Any user visible operations will be
+     * delegated to the associated UI operator.
+     * @param uiOperator the UI operator to be associated.
+     */
+    public void setUiOperator(final TextDecoratorUiOperator uiOperator) {
+        mUiOperator.disposeUi();
+        mUiOperator = uiOperator;
+        mUiOperator.setOnClickListener(getOnClickHandler());
+    }
+
+    private final Runnable mDefaultOnClickHandler = new Runnable() {
+        @Override
+        public void run() {
+            onClickIndicator();
+        }
+    };
+
+    @UsedForTesting
+    final Runnable getOnClickHandler() {
+        return mDefaultOnClickHandler;
+    }
+
+    /**
+     * Shows the "Commit" indicator and associates it with the given suggested word.
+     *
+     * <p>The effect of {@link #showCommitIndicator(SuggestedWordInfo)} and
+     * {@link #showAddToDictionaryIndicator(SuggestedWordInfo)} are exclusive to each other. Call
+     * {@link #reset()} to hide the indicator.</p>
+     *
+     * @param wordInfo the suggested word which should be associated with the indicator. This object
+     * will be passed back in {@link Listener#onClickComposingTextToCommit(SuggestedWordInfo)}
+     */
+    public void showCommitIndicator(final SuggestedWordInfo wordInfo) {
+        if (mMode == MODE_COMMIT && wordInfo != null &&
+                TextUtils.equals(mWaitingWord.mWord, wordInfo.mWord)) {
+            // Skip layout for better performance.
+            return;
+        }
+        mWaitingWord = wordInfo;
+        mMode = MODE_COMMIT;
+        layoutLater();
+    }
+
+    /**
+     * Shows the "Add to dictionary" indicator and associates it with associating the given
+     * suggested word.
+     *
+     * <p>The effect of {@link #showCommitIndicator(SuggestedWordInfo)} and
+     * {@link #showAddToDictionaryIndicator(SuggestedWordInfo)} are exclusive to each other. Call
+     * {@link #reset()} to hide the indicator.</p>
+     *
+     * @param wordInfo the suggested word which should be associated with the indicator. This object
+     * will be passed back in
+     * {@link Listener#onClickComposingTextToAddToDictionary(SuggestedWordInfo)}.
+     */
+    public void showAddToDictionaryIndicator(final SuggestedWordInfo wordInfo) {
+        if (mMode == MODE_ADD_TO_DICTIONARY && wordInfo != null &&
+                TextUtils.equals(mWaitingWord.mWord, wordInfo.mWord)) {
+            // Skip layout for better performance.
+            return;
+        }
+        mWaitingWord = wordInfo;
+        mMode = MODE_ADD_TO_DICTIONARY;
+        layoutLater();
+        return;
+    }
+
+    /**
+     * Must be called when the input method is about changing to for from the full screen mode.
+     * @param fullScreenMode {@code true} if the input method is entering the full screen mode.
+     * {@code false} is the input method is finishing the full screen mode.
+     */
+    public void notifyFullScreenMode(final boolean fullScreenMode) {
+        final boolean currentFullScreenMode = mIsFullScreenMode;
+        if (!currentFullScreenMode && fullScreenMode) {
+            // Currently full screen mode is not supported.
+            // TODO: Support full screen mode.
+            hideIndicator();
+        }
+        mIsFullScreenMode = fullScreenMode;
+    }
+
+    /**
+     * Resets previous requests and makes indicator invisible.
+     */
+    public void reset() {
+        mWaitingWord = null;
+        mMode = MODE_NONE;
+        mLocalOrigin.set(0.0f, 0.0f);
+        mRelativeIndicatorBounds.set(0.0f, 0.0f, 0.0f, 0.0f);
+        mRelativeComposingTextBounds.set(0.0f, 0.0f, 0.0f, 0.0f);
+        cancelLayoutInternalExpectedly("Resetting internal state.");
+    }
+
+    /**
+     * Must be called when the {@link InputMethodService#onUpdateCursorAnchorInfo()} is called.
+     *
+     * <p>CAVEAT: Currently the input method author is responsible for ignoring
+     * {@link InputMethodService#onUpdateCursorAnchorInfo()} called in full screen mode.</p>
+     * @param info the compatibility wrapper object for the received {@link CursorAnchorInfo}.
+     */
+    public void onUpdateCursorAnchorInfo(final CursorAnchorInfoCompatWrapper info) {
+        if (mIsFullScreenMode) {
+            // TODO: Consider to call InputConnection#requestCursorAnchorInfo to disable the
+            // event callback to suppress unnecessary event callbacks.
+            return;
+        }
+        mCursorAnchorInfoWrapper = info;
+        // Do not use layoutLater() to minimize the latency.
+        layoutImmediately();
+    }
+
+    private void hideIndicator() {
+        mUiOperator.hideUi();
+    }
+
+    private void cancelLayoutInternalUnexpectedly(final String message) {
+        hideIndicator();
+        Log.d(TAG, message);
+    }
+
+    private void cancelLayoutInternalExpectedly(final String message) {
+        hideIndicator();
+        if (DEBUG) {
+            Log.d(TAG, message);
+        }
+    }
+
+    private void layoutLater() {
+        mLayoutInvalidator.invalidateLayout();
+    }
+
+
+    private void layoutImmediately() {
+        // Clear pending layout requests.
+        mLayoutInvalidator.cancelInvalidateLayout();
+        layoutMain();
+    }
+
+    private void layoutMain() {
+        if (mIsFullScreenMode) {
+            cancelLayoutInternalUnexpectedly("Full screen mode isn't yet supported.");
+            return;
+        }
+
+        if (mMode != MODE_COMMIT && mMode != MODE_ADD_TO_DICTIONARY) {
+            if (mMode == MODE_NONE) {
+                cancelLayoutInternalExpectedly("Not ready for layouting.");
+            } else {
+                cancelLayoutInternalUnexpectedly("Unknown mMode=" + mMode);
+            }
+            return;
+        }
+
+        final CursorAnchorInfoCompatWrapper info = mCursorAnchorInfoWrapper;
+
+        if (info == null) {
+            cancelLayoutInternalExpectedly("CursorAnchorInfo isn't available.");
+            return;
+        }
+
+        final Matrix matrix = info.getMatrix();
+        if (matrix == null) {
+            cancelLayoutInternalUnexpectedly("Matrix is null");
+        }
+
+        final CharSequence composingText = info.getComposingText();
+        if (mMode == MODE_COMMIT) {
+            if (composingText == null) {
+                cancelLayoutInternalExpectedly("composingText is null.");
+                return;
+            }
+            final int composingTextStart = info.getComposingTextStart();
+            final int lastCharRectIndex = composingTextStart + composingText.length() - 1;
+            final RectF lastCharRect = info.getCharacterRect(lastCharRectIndex);
+            final int lastCharRectFlag = info.getCharacterRectFlags(lastCharRectIndex);
+            final int lastCharRectType =
+                    lastCharRectFlag & CursorAnchorInfoCompatWrapper.CHARACTER_RECT_TYPE_MASK;
+            if (lastCharRect == null || matrix == null || lastCharRectType !=
+                    CursorAnchorInfoCompatWrapper.CHARACTER_RECT_TYPE_FULLY_VISIBLE) {
+                hideIndicator();
+                return;
+            }
+            final RectF segmentStartCharRect = new RectF(lastCharRect);
+            for (int i = composingText.length() - 2; i >= 0; --i) {
+                final RectF charRect = info.getCharacterRect(composingTextStart + i);
+                if (charRect == null) {
+                    break;
+                }
+                if (charRect.top != segmentStartCharRect.top) {
+                    break;
+                }
+                if (charRect.bottom != segmentStartCharRect.bottom) {
+                    break;
+                }
+                segmentStartCharRect.set(charRect);
+            }
+
+            mLocalOrigin.set(lastCharRect.right, lastCharRect.top);
+            mRelativeIndicatorBounds.set(lastCharRect.right, lastCharRect.top,
+                    lastCharRect.right + lastCharRect.height(), lastCharRect.bottom);
+            mRelativeIndicatorBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
+
+            mRelativeIndicatorBounds.set(lastCharRect.right, lastCharRect.top,
+                    lastCharRect.right + lastCharRect.height(), lastCharRect.bottom);
+            mRelativeIndicatorBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
+
+            mRelativeComposingTextBounds.set(segmentStartCharRect.left, segmentStartCharRect.top,
+                    segmentStartCharRect.right, segmentStartCharRect.bottom);
+            mRelativeComposingTextBounds.offset(-mLocalOrigin.x, -mLocalOrigin.y);
+
+            if (mWaitingWord == null) {
+                cancelLayoutInternalExpectedly("mWaitingText is null.");
+                return;
+            }
+            if (TextUtils.isEmpty(mWaitingWord.mWord)) {
+                cancelLayoutInternalExpectedly("mWaitingText.mWord is empty.");
+                return;
+            }
+            if (!TextUtils.equals(composingText, mWaitingWord.mWord)) {
+                // This is indeed an expected situation because of the asynchronous nature of
+                // input method framework in Android. Note that composingText is notified from the
+                // application, while mWaitingWord.mWord is obtained directly from the InputLogic.
+                cancelLayoutInternalExpectedly(
+                        "Composing text doesn't match the one we are waiting for.");
+                return;
+            }
+        } else {
+            if (!TextUtils.isEmpty(composingText)) {
+                // This is an unexpected case.
+                // TODO: Document this.
+                hideIndicator();
+                return;
+            }
+            // In MODE_ADD_TO_DICTIONARY, we cannot retrieve the character position at all because
+            // of the lack of composing text. We will use the insertion marker position instead.
+            if (info.isInsertionMarkerClipped()) {
+                hideIndicator();
+                return;
+            }
+            final float insertionMarkerHolizontal = info.getInsertionMarkerHorizontal();
+            final float insertionMarkerTop = info.getInsertionMarkerTop();
+            mLocalOrigin.set(insertionMarkerHolizontal, insertionMarkerTop);
+        }
+
+        final RectF indicatorBounds = new RectF(mRelativeIndicatorBounds);
+        final RectF composingTextBounds = new RectF(mRelativeComposingTextBounds);
+        indicatorBounds.offset(mLocalOrigin.x, mLocalOrigin.y);
+        composingTextBounds.offset(mLocalOrigin.x, mLocalOrigin.y);
+        mUiOperator.layoutUi(mMode == MODE_COMMIT, matrix, indicatorBounds, composingTextBounds);
+    }
+
+    private void onClickIndicator() {
+        if (mWaitingWord == null || TextUtils.isEmpty(mWaitingWord.mWord)) {
+            return;
+        }
+        switch (mMode) {
+            case MODE_COMMIT:
+                mListener.onClickComposingTextToCommit(mWaitingWord);
+                break;
+            case MODE_ADD_TO_DICTIONARY:
+                mListener.onClickComposingTextToAddToDictionary(mWaitingWord);
+                break;
+        }
+    }
+
+    private final LayoutInvalidator mLayoutInvalidator = new LayoutInvalidator(this);
+
+    /**
+     * Used for managing pending layout tasks for {@link TextDecorator#layoutLater()}.
+     */
+    private static final class LayoutInvalidator {
+        private final HandlerImpl mHandler;
+        public LayoutInvalidator(final TextDecorator ownerInstance) {
+            mHandler = new HandlerImpl(ownerInstance);
+        }
+
+        private static final int MSG_LAYOUT = 0;
+
+        private static final class HandlerImpl
+                extends LeakGuardHandlerWrapper<TextDecorator> {
+            public HandlerImpl(final TextDecorator ownerInstance) {
+                super(ownerInstance);
+            }
+
+            @Override
+            public void handleMessage(final Message msg) {
+                final TextDecorator owner = getOwnerInstance();
+                if (owner == null) {
+                    return;
+                }
+                switch (msg.what) {
+                    case MSG_LAYOUT:
+                        owner.layoutMain();
+                        break;
+                }
+            }
+        }
+
+        /**
+         * Puts a layout task into the scheduler. Does nothing if one or more layout tasks are
+         * already scheduled.
+         */
+        public void invalidateLayout() {
+            if (!mHandler.hasMessages(MSG_LAYOUT)) {
+                mHandler.obtainMessage(MSG_LAYOUT).sendToTarget();
+            }
+        }
+
+        /**
+         * Clears the pending layout tasks.
+         */
+        public void cancelInvalidateLayout() {
+            mHandler.removeMessages(MSG_LAYOUT);
+        }
+    }
+
+    private final static Listener EMPTY_LISTENER = new Listener() {
+        @Override
+        public void onClickComposingTextToCommit(SuggestedWordInfo wordInfo) {
+        }
+        @Override
+        public void onClickComposingTextToAddToDictionary(SuggestedWordInfo wordInfo) {
+        }
+    };
+
+    private final static TextDecoratorUiOperator EMPTY_UI_OPERATOR = new TextDecoratorUiOperator() {
+        @Override
+        public void disposeUi() {
+        }
+        @Override
+        public void hideUi() {
+        }
+        @Override
+        public void setOnClickListener(Runnable listener) {
+        }
+        @Override
+        public void layoutUi(boolean isCommitMode, Matrix matrix, RectF indicatorBounds,
+                RectF composingTextBounds) {
+        }
+    };
+}
diff --git a/java/src/com/android/inputmethod/keyboard/TextDecoratorUi.java b/java/src/com/android/inputmethod/keyboard/TextDecoratorUi.java
new file mode 100644
index 0000000..6e215a9
--- /dev/null
+++ b/java/src/com/android/inputmethod/keyboard/TextDecoratorUi.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.keyboard;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.drawable.ColorDrawable;
+import android.inputmethodservice.InputMethodService;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewParent;
+import android.widget.PopupWindow;
+import android.widget.RelativeLayout;
+
+import com.android.inputmethod.latin.R;
+
+/**
+ * Used as the UI component of {@link TextDecorator}.
+ */
+public final class TextDecoratorUi implements TextDecoratorUiOperator {
+    private static final boolean VISUAL_DEBUG = false;
+    private static final int VISUAL_DEBUG_HIT_AREA_COLOR = 0x80ff8000;
+
+    private final RelativeLayout mLocalRootView;
+    private final CommitIndicatorView mCommitIndicatorView;
+    private final AddToDictionaryIndicatorView mAddToDictionaryIndicatorView;
+    private final PopupWindow mTouchEventWindow;
+    private final View mTouchEventWindowClickListenerView;
+    private final float mHitAreaMarginInPixels;
+
+    /**
+     * This constructor is designed to be called from {@link InputMethodService#setInputView(View)}.
+     * Other usages are not supported.
+     *
+     * @param context the context of the input method.
+     * @param inputView the view that is passed to {@link InputMethodService#setInputView(View)}.
+     */
+    public TextDecoratorUi(final Context context, final View inputView) {
+        final Resources resources = context.getResources();
+        final int hitAreaMarginInDP = resources.getInteger(
+                R.integer.text_decorator_hit_area_margin_in_dp);
+        mHitAreaMarginInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
+                hitAreaMarginInDP, resources.getDisplayMetrics());
+
+        mLocalRootView = new RelativeLayout(context);
+        mLocalRootView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT,
+                LayoutParams.MATCH_PARENT));
+        // TODO: Use #setBackground(null) for API Level >= 16.
+        mLocalRootView.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+
+        final ViewGroup contentView = getContentView(inputView);
+        mCommitIndicatorView = new CommitIndicatorView(context);
+        mAddToDictionaryIndicatorView = new AddToDictionaryIndicatorView(context);
+        mLocalRootView.addView(mCommitIndicatorView);
+        mLocalRootView.addView(mAddToDictionaryIndicatorView);
+        if (contentView != null) {
+            contentView.addView(mLocalRootView);
+        }
+
+        // This popup window is used to avoid the limitation that the input method is not able to
+        // observe the touch events happening outside of InputMethodService.Insets#touchableRegion.
+        // We don't use this popup window for rendering the UI for performance reasons though.
+        mTouchEventWindow = new PopupWindow(context);
+        if (VISUAL_DEBUG) {
+            mTouchEventWindow.setBackgroundDrawable(new ColorDrawable(VISUAL_DEBUG_HIT_AREA_COLOR));
+        } else {
+            mTouchEventWindow.setBackgroundDrawable(null);
+        }
+        mTouchEventWindowClickListenerView = new View(context);
+        mTouchEventWindow.setContentView(mTouchEventWindowClickListenerView);
+    }
+
+    @Override
+    public void disposeUi() {
+        if (mLocalRootView != null) {
+            final ViewParent parent = mLocalRootView.getParent();
+            if (parent != null && parent instanceof ViewGroup) {
+                ((ViewGroup) parent).removeView(mLocalRootView);
+            }
+            mLocalRootView.removeAllViews();
+        }
+        if (mTouchEventWindow != null) {
+            mTouchEventWindow.dismiss();
+        }
+    }
+
+    @Override
+    public void hideUi() {
+        mCommitIndicatorView.setVisibility(View.GONE);
+        mAddToDictionaryIndicatorView.setVisibility(View.GONE);
+        mTouchEventWindow.dismiss();
+    }
+
+    @Override
+    public void layoutUi(final boolean isCommitMode, final Matrix matrix,
+            final RectF indicatorBounds, final RectF composingTextBounds) {
+        final RectF indicatorBoundsInScreenCoordinates = new RectF();
+        matrix.mapRect(indicatorBoundsInScreenCoordinates, indicatorBounds);
+        mCommitIndicatorView.setBounds(indicatorBoundsInScreenCoordinates);
+        mAddToDictionaryIndicatorView.setBounds(indicatorBoundsInScreenCoordinates);
+
+        final RectF hitAreaBounds = new RectF(composingTextBounds);
+        hitAreaBounds.union(indicatorBounds);
+        final RectF hitAreaBoundsInScreenCoordinates = new RectF();
+        matrix.mapRect(hitAreaBoundsInScreenCoordinates, hitAreaBounds);
+        hitAreaBoundsInScreenCoordinates.inset(-mHitAreaMarginInPixels, -mHitAreaMarginInPixels);
+
+        final int[] originScreen = new int[2];
+        mLocalRootView.getLocationOnScreen(originScreen);
+        final int viewOriginX = originScreen[0];
+        final int viewOriginY = originScreen[1];
+
+        final View toBeShown;
+        final View toBeHidden;
+        if (isCommitMode) {
+            toBeShown = mCommitIndicatorView;
+            toBeHidden = mAddToDictionaryIndicatorView;
+        } else {
+            toBeShown = mAddToDictionaryIndicatorView;
+            toBeHidden = mCommitIndicatorView;
+        }
+        toBeShown.setX(indicatorBoundsInScreenCoordinates.left - viewOriginX);
+        toBeShown.setY(indicatorBoundsInScreenCoordinates.top - viewOriginY);
+        toBeShown.setVisibility(View.VISIBLE);
+        toBeHidden.setVisibility(View.GONE);
+
+        if (mTouchEventWindow.isShowing()) {
+            mTouchEventWindow.update((int)hitAreaBoundsInScreenCoordinates.left - viewOriginX,
+                    (int)hitAreaBoundsInScreenCoordinates.top - viewOriginY,
+                    (int)hitAreaBoundsInScreenCoordinates.width(),
+                    (int)hitAreaBoundsInScreenCoordinates.height());
+        } else {
+            mTouchEventWindow.setWidth((int)hitAreaBoundsInScreenCoordinates.width());
+            mTouchEventWindow.setHeight((int)hitAreaBoundsInScreenCoordinates.height());
+            mTouchEventWindow.showAtLocation(mLocalRootView, Gravity.NO_GRAVITY,
+                    (int)hitAreaBoundsInScreenCoordinates.left - viewOriginX,
+                    (int)hitAreaBoundsInScreenCoordinates.top - viewOriginY);
+        }
+    }
+
+    @Override
+    public void setOnClickListener(final Runnable listener) {
+        mTouchEventWindowClickListenerView.setOnClickListener(new OnClickListener() {
+            @Override
+            public void onClick(final View arg0) {
+                listener.run();
+            }
+        });
+    }
+
+    private static class IndicatorView extends View {
+        private final Path mPath;
+        private final Path mTmpPath = new Path();
+        private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+        private final Matrix mMatrix = new Matrix();
+        private final int mBackgroundColor;
+        private final int mForegroundColor;
+        private final RectF mBounds = new RectF();
+        public IndicatorView(Context context, final int pathResourceId,
+                final int sizeResourceId, final int backgroundColorResourceId,
+                final int foregroundColroResourceId) {
+            super(context);
+            final Resources resources = context.getResources();
+            mPath = createPath(resources, pathResourceId, sizeResourceId);
+            mBackgroundColor = resources.getColor(backgroundColorResourceId);
+            mForegroundColor = resources.getColor(foregroundColroResourceId);
+        }
+
+        public void setBounds(final RectF rect) {
+            mBounds.set(rect);
+        }
+
+        @Override
+        protected void onDraw(Canvas canvas) {
+            mPaint.setColor(mBackgroundColor);
+            mPaint.setStyle(Paint.Style.FILL);
+            canvas.drawRect(0.0f, 0.0f, mBounds.width(), mBounds.height(), mPaint);
+
+            mMatrix.reset();
+            mMatrix.postScale(mBounds.width(), mBounds.height());
+            mPath.transform(mMatrix, mTmpPath);
+            mPaint.setColor(mForegroundColor);
+            canvas.drawPath(mTmpPath, mPaint);
+        }
+
+        private static Path createPath(final Resources resources, final int pathResourceId,
+                final int sizeResourceId) {
+            final int size = resources.getInteger(sizeResourceId);
+            final float normalizationFactor = 1.0f / size;
+            final int[] array = resources.getIntArray(pathResourceId);
+
+            final Path path = new Path();
+            for (int i = 0; i < array.length; i += 2) {
+                if (i == 0) {
+                    path.moveTo(array[i] * normalizationFactor, array[i + 1] * normalizationFactor);
+                } else {
+                    path.lineTo(array[i] * normalizationFactor, array[i + 1] * normalizationFactor);
+                }
+            }
+            path.close();
+            return path;
+        }
+    }
+
+    private static ViewGroup getContentView(final View view) {
+        final View rootView = view.getRootView();
+        if (rootView == null) {
+            return null;
+        }
+
+        final ViewGroup windowContentView = (ViewGroup)rootView.findViewById(android.R.id.content);
+        if (windowContentView == null) {
+            return null;
+        }
+        return windowContentView;
+    }
+
+    private static final class CommitIndicatorView extends TextDecoratorUi.IndicatorView {
+        public CommitIndicatorView(final Context context) {
+            super(context, R.array.text_decorator_commit_indicator_path,
+                    R.integer.text_decorator_commit_indicator_path_size,
+                    R.color.text_decorator_commit_indicator_background_color,
+                    R.color.text_decorator_commit_indicator_foreground_color);
+        }
+    }
+
+    private static final class AddToDictionaryIndicatorView extends TextDecoratorUi.IndicatorView {
+        public AddToDictionaryIndicatorView(final Context context) {
+            super(context, R.array.text_decorator_add_to_dictionary_indicator_path,
+                    R.integer.text_decorator_add_to_dictionary_indicator_path_size,
+                    R.color.text_decorator_add_to_dictionary_indicator_background_color,
+                    R.color.text_decorator_add_to_dictionary_indicator_foreground_color);
+        }
+    }
+}
\ No newline at end of file
diff --git a/java/src/com/android/inputmethod/keyboard/TextDecoratorUiOperator.java b/java/src/com/android/inputmethod/keyboard/TextDecoratorUiOperator.java
new file mode 100644
index 0000000..f84e12d
--- /dev/null
+++ b/java/src/com/android/inputmethod/keyboard/TextDecoratorUiOperator.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.keyboard;
+
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.graphics.RectF;
+
+/**
+ * This interface defines how UI operations required for {@link TextDecorator} are delegated to
+ * the actual UI implementation class.
+ */
+public interface TextDecoratorUiOperator {
+    /**
+     * Called to notify that the UI is ready to be disposed.
+     */
+    void disposeUi();
+
+    /**
+     * Called when the UI should become invisible.
+     */
+    void hideUi();
+
+    /**
+     * Called to set the new click handler.
+     * @param onClickListener the callback object whose {@link Runnable#run()} should be called when
+     * the indicator is clicked.
+     */
+    void setOnClickListener(final Runnable onClickListener);
+
+    /**
+     * Called when the layout should be updated.
+     * @param isCommitMode {@code true} if the commit indicator should be shown. Show the
+     * add-to-dictionary indicator otherwise.
+     * @param matrix The matrix that transforms the local coordinates into the screen coordinates.
+     * @param indicatorBounds The bounding box of the indicator, in local coordinates.
+     * @param composingTextBounds The bounding box of the composing text, in local coordinates.
+     */
+    void layoutUi(final boolean isCommitMode, final Matrix matrix, final RectF indicatorBounds,
+            final RectF composingTextBounds);
+}
diff --git a/java/src/com/android/inputmethod/latin/LatinIME.java b/java/src/com/android/inputmethod/latin/LatinIME.java
index aebc710..dcafd83 100644
--- a/java/src/com/android/inputmethod/latin/LatinIME.java
+++ b/java/src/com/android/inputmethod/latin/LatinIME.java
@@ -69,6 +69,7 @@
 import com.android.inputmethod.keyboard.KeyboardId;
 import com.android.inputmethod.keyboard.KeyboardSwitcher;
 import com.android.inputmethod.keyboard.MainKeyboardView;
+import com.android.inputmethod.keyboard.TextDecoratorUi;
 import com.android.inputmethod.latin.Suggest.OnGetSuggestedWordsCallback;
 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
 import com.android.inputmethod.latin.define.DebugFlags;
@@ -183,8 +184,9 @@
         private static final int MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED = 6;
         private static final int MSG_RESET_CACHES = 7;
         private static final int MSG_WAIT_FOR_DICTIONARY_LOAD = 8;
+        private static final int MSG_SHOW_COMMIT_INDICATOR = 9;
         // Update this when adding new messages
-        private static final int MSG_LAST = MSG_WAIT_FOR_DICTIONARY_LOAD;
+        private static final int MSG_LAST = MSG_SHOW_COMMIT_INDICATOR;
 
         private static final int ARG1_NOT_GESTURE_INPUT = 0;
         private static final int ARG1_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT = 1;
@@ -195,6 +197,7 @@
 
         private int mDelayInMillisecondsToUpdateSuggestions;
         private int mDelayInMillisecondsToUpdateShiftState;
+        private int mDelayInMillisecondsToShowCommitIndicator;
 
         public UIHandler(final LatinIME ownerInstance) {
             super(ownerInstance);
@@ -206,10 +209,12 @@
                 return;
             }
             final Resources res = latinIme.getResources();
-            mDelayInMillisecondsToUpdateSuggestions =
-                    res.getInteger(R.integer.config_delay_in_milliseconds_to_update_suggestions);
-            mDelayInMillisecondsToUpdateShiftState =
-                    res.getInteger(R.integer.config_delay_in_milliseconds_to_update_shift_state);
+            mDelayInMillisecondsToUpdateSuggestions = res.getInteger(
+                    R.integer.config_delay_in_milliseconds_to_update_suggestions);
+            mDelayInMillisecondsToUpdateShiftState = res.getInteger(
+                    R.integer.config_delay_in_milliseconds_to_update_shift_state);
+            mDelayInMillisecondsToShowCommitIndicator = res.getInteger(
+                    R.integer.text_decorator_delay_in_milliseconds_to_show_commit_indicator);
         }
 
         @Override
@@ -258,7 +263,7 @@
             case MSG_RESET_CACHES:
                 final SettingsValues settingsValues = latinIme.mSettings.getCurrent();
                 if (latinIme.mInputLogic.retryResetCachesAndReturnSuccess(
-                        msg.arg1 == 1 /* tryResumeSuggestions */,
+                        msg.arg1 == ARG1_TRUE /* tryResumeSuggestions */,
                         msg.arg2 /* remainingTries */, this /* handler */)) {
                     // If we were able to reset the caches, then we can reload the keyboard.
                     // Otherwise, we'll do it when we can.
@@ -267,6 +272,14 @@
                             latinIme.getCurrentRecapitalizeState());
                 }
                 break;
+            case MSG_SHOW_COMMIT_INDICATOR:
+                // Protocol of MSG_SET_COMMIT_INDICATOR_ENABLED:
+                // - what: MSG_SHOW_COMMIT_INDICATOR
+                // - arg1: not used.
+                // - arg2: not used.
+                // - obj:  the Runnable object to be called back.
+                ((Runnable) msg.obj).run();
+                break;
             case MSG_WAIT_FOR_DICTIONARY_LOAD:
                 Log.i(TAG, "Timeout waiting for dictionary load");
                 break;
@@ -367,6 +380,19 @@
             obtainMessage(MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED, suggestedWords).sendToTarget();
         }
 
+        /**
+         * Posts a delayed task to show the commit indicator.
+         *
+         * <p>Only one task can exist in the queue. When this method is called, any prior task that
+         * has not yet fired will be canceled.</p>
+         * @param task the runnable object that will be fired when the delayed task is dispatched.
+         */
+        public void postShowCommitIndicatorTask(final Runnable task) {
+            removeMessages(MSG_SHOW_COMMIT_INDICATOR);
+            sendMessageDelayed(obtainMessage(MSG_SHOW_COMMIT_INDICATOR, task),
+                    mDelayInMillisecondsToShowCommitIndicator);
+        }
+
         // Working variables for the following methods.
         private boolean mIsOrientationChanging;
         private boolean mPendingSuccessiveImsCallback;
@@ -717,6 +743,7 @@
         if (hasSuggestionStripView()) {
             mSuggestionStripView.setListener(this, view);
         }
+        mInputLogic.setTextDecoratorUi(new TextDecoratorUi(this, view));
     }
 
     @Override
@@ -972,9 +999,7 @@
     // @Override
     public void onUpdateCursorAnchorInfo(final CursorAnchorInfo info) {
         if (ProductionFlags.ENABLE_CURSOR_ANCHOR_INFO_CALLBACK) {
-            final CursorAnchorInfoCompatWrapper wrapper =
-                    CursorAnchorInfoCompatWrapper.fromObject(info);
-            // TODO: Implement here
+            mInputLogic.onUpdateCursorAnchorInfo(CursorAnchorInfoCompatWrapper.fromObject(info));
         }
     }
 
@@ -1178,6 +1203,7 @@
         // In fullscreen mode, no need to have extra space to show the key preview.
         // If not, we should have extra space above the keyboard to show the key preview.
         mKeyPreviewBackingView.setVisibility(isFullscreenMode() ? View.GONE : View.VISIBLE);
+        mInputLogic.onUpdateFullscreenMode(isFullscreenMode());
     }
 
     private int getCurrentAutoCapsState() {
@@ -1221,6 +1247,7 @@
             wordToEdit = word;
         }
         mDictionaryFacilitator.addWordToUserDictionary(this /* context */, wordToEdit);
+        mInputLogic.onAddWordToUserDictionary();
     }
 
     // Callback for the {@link SuggestionStripView}, to call when the important notice strip is
@@ -1409,7 +1436,8 @@
     }
 
     private void setSuggestedWords(final SuggestedWords suggestedWords) {
-        mInputLogic.setSuggestedWords(suggestedWords);
+        final SettingsValues currentSettingsValues = mSettings.getCurrent();
+        mInputLogic.setSuggestedWords(suggestedWords, currentSettingsValues, mHandler);
         // TODO: Modify this when we support suggestions with hard keyboard
         if (!hasSuggestionStripView()) {
             return;
@@ -1418,7 +1446,6 @@
             return;
         }
 
-        final SettingsValues currentSettingsValues = mSettings.getCurrent();
         final boolean shouldShowImportantNotice =
                 ImportantNoticeUtils.shouldShowImportantNotice(this);
         final boolean shouldShowSuggestionCandidates =
diff --git a/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java b/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java
index a697880..0f2ba53 100644
--- a/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java
+++ b/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java
@@ -17,6 +17,7 @@
 package com.android.inputmethod.latin.inputlogic;
 
 import android.graphics.Color;
+import android.inputmethodservice.InputMethodService;
 import android.os.SystemClock;
 import android.text.SpannableString;
 import android.text.TextUtils;
@@ -27,11 +28,14 @@
 import android.view.inputmethod.CorrectionInfo;
 import android.view.inputmethod.EditorInfo;
 
+import com.android.inputmethod.compat.CursorAnchorInfoCompatWrapper;
 import com.android.inputmethod.compat.SuggestionSpanUtils;
 import com.android.inputmethod.event.Event;
 import com.android.inputmethod.event.InputTransaction;
 import com.android.inputmethod.keyboard.KeyboardSwitcher;
 import com.android.inputmethod.keyboard.ProximityInfo;
+import com.android.inputmethod.keyboard.TextDecorator;
+import com.android.inputmethod.keyboard.TextDecoratorUiOperator;
 import com.android.inputmethod.latin.Constants;
 import com.android.inputmethod.latin.Dictionary;
 import com.android.inputmethod.latin.DictionaryFacilitator;
@@ -81,6 +85,18 @@
     public final Suggest mSuggest;
     private final DictionaryFacilitator mDictionaryFacilitator;
 
+    private final TextDecorator mTextDecorator = new TextDecorator(new TextDecorator.Listener() {
+        @Override
+        public void onClickComposingTextToCommit(SuggestedWordInfo wordInfo) {
+            mLatinIME.pickSuggestionManually(wordInfo);
+        }
+        @Override
+        public void onClickComposingTextToAddToDictionary(SuggestedWordInfo wordInfo) {
+            mLatinIME.addWordToUserDictionary(wordInfo.mWord);
+            mLatinIME.dismissAddToDictionaryHint();
+        }
+    });
+
     public LastComposedWord mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD;
     // This has package visibility so it can be accessed from InputLogicHandler.
     /* package */ final WordComposer mWordComposer;
@@ -303,8 +319,18 @@
             return inputTransaction;
         }
 
-        commitChosenWord(settingsValues, suggestion,
-                LastComposedWord.COMMIT_TYPE_MANUAL_PICK, LastComposedWord.NOT_A_SEPARATOR);
+        final boolean shouldShowAddToDictionaryHint = shouldShowAddToDictionaryHint(suggestionInfo);
+        final boolean shouldShowAddToDictionaryIndicator =
+                shouldShowAddToDictionaryHint && settingsValues.mShouldShowUiToAcceptTypedWord;
+        final int backgroundColor;
+        if (shouldShowAddToDictionaryIndicator) {
+            backgroundColor = settingsValues.mTextHighlightColorForAddToDictionaryIndicator;
+        } else {
+            backgroundColor = Color.TRANSPARENT;
+        }
+        commitChosenWordWithBackgroundColor(settingsValues, suggestion,
+                LastComposedWord.COMMIT_TYPE_MANUAL_PICK, LastComposedWord.NOT_A_SEPARATOR,
+                backgroundColor);
         mConnection.endBatchEdit();
         // Don't allow cancellation of manual pick
         mLastComposedWord.deactivate();
@@ -312,13 +338,16 @@
         mSpaceState = SpaceState.PHANTOM;
         inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW);
 
-        if (shouldShowAddToDictionaryHint(suggestionInfo)) {
+        if (shouldShowAddToDictionaryHint) {
             mSuggestionStripViewAccessor.showAddToDictionaryHint(suggestion);
         } else {
             // If we're not showing the "Touch again to save", then update the suggestion strip.
             // That's going to be predictions (or punctuation suggestions), so INPUT_STYLE_NONE.
             handler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_NONE);
         }
+        if (shouldShowAddToDictionaryIndicator) {
+            mTextDecorator.showAddToDictionaryIndicator(suggestionInfo);
+        }
         return inputTransaction;
     }
 
@@ -386,6 +415,8 @@
 
         // The cursor has been moved : we now accept to perform recapitalization
         mRecapitalizeStatus.enable();
+        // We moved the cursor and need to invalidate the indicator right now.
+        mTextDecorator.reset();
         // We moved the cursor. If we are touching a word, we need to resume suggestion.
         mLatinIME.mHandler.postResumeSuggestions(false /* shouldIncludeResumedWordInSuggestions */,
                 true /* shouldDelay */);
@@ -561,7 +592,8 @@
 
     // TODO: on the long term, this method should become private, but it will be difficult.
     // Especially, how do we deal with InputMethodService.onDisplayCompletions?
-    public void setSuggestedWords(final SuggestedWords suggestedWords) {
+    public void setSuggestedWords(final SuggestedWords suggestedWords,
+            final SettingsValues settingsValues, final LatinIME.UIHandler handler) {
         if (SuggestedWords.EMPTY != suggestedWords) {
             final String autoCorrection;
             if (suggestedWords.mWillAutoCorrect) {
@@ -575,6 +607,38 @@
         }
         mSuggestedWords = suggestedWords;
         final boolean newAutoCorrectionIndicator = suggestedWords.mWillAutoCorrect;
+        if (shouldShowCommitIndicator(suggestedWords, settingsValues)) {
+            // typedWordInfo is never null here.
+            final SuggestedWordInfo typedWordInfo = suggestedWords.getTypedWordInfoOrNull();
+            handler.postShowCommitIndicatorTask(new Runnable() {
+                @Override
+                public void run() {
+                    // TODO: This needs to be refactored to ensure that mWordComposer is accessed
+                    // only from the UI thread.
+                    if (!mWordComposer.isComposingWord()) {
+                        mTextDecorator.reset();
+                        return;
+                    }
+                    final SuggestedWordInfo currentTypedWordInfo =
+                            mSuggestedWords.getTypedWordInfoOrNull();
+                    if (currentTypedWordInfo == null) {
+                        mTextDecorator.reset();
+                        return;
+                    }
+                    if (!currentTypedWordInfo.equals(typedWordInfo)) {
+                        // Suggested word has been changed. This task is obsolete.
+                        mTextDecorator.reset();
+                        return;
+                    }
+                    mTextDecorator.showCommitIndicator(typedWordInfo);
+                }
+            });
+        } else {
+            // Note: It is OK to not cancel previous postShowCommitIndicatorTask() here. Having a
+            // cancellation mechanism could improve performance a bit though.
+            mTextDecorator.reset();
+        }
+
         // Put a blue underline to a word in TextView which will be auto-corrected.
         if (mIsAutoCorrectionIndicatorOn != newAutoCorrectionIndicator
                 && mWordComposer.isComposingWord()) {
@@ -756,6 +820,8 @@
         if (!mWordComposer.isComposingWord() &&
                 mSuggestionStripViewAccessor.isShowingAddToDictionaryHint()) {
             mSuggestionStripViewAccessor.dismissAddToDictionaryHint();
+            mConnection.removeBackgroundColorFromHighlightedTextIfNecessary();
+            mTextDecorator.reset();
         }
 
         final int codePoint = event.mCodePoint;
@@ -2108,4 +2174,74 @@
                 settingsValues.mAutoCorrectionEnabledPerUserSettings,
                 inputStyle, sequenceNumber, callback);
     }
+
+    //////////////////////////////////////////////////////////////////////////////////////////////
+    // Following methods are tentatively placed in this class for the integration with
+    // TextDecorator.
+    // TODO: Decouple things that are not related to the input logic.
+    //////////////////////////////////////////////////////////////////////////////////////////////
+
+    /**
+     * Sets the UI operator for {@link TextDecorator}.
+     * @param uiOperator the UI operator which should be associated with {@link TextDecorator}.
+     */
+    public void setTextDecoratorUi(final TextDecoratorUiOperator uiOperator) {
+        mTextDecorator.setUiOperator(uiOperator);
+    }
+
+    /**
+     * Must be called from {@link InputMethodService#onUpdateCursorAnchorInfo} is called.
+     * @param info The wrapper object with which we can access cursor/anchor info.
+     */
+    public void onUpdateCursorAnchorInfo(final CursorAnchorInfoCompatWrapper info) {
+        mTextDecorator.onUpdateCursorAnchorInfo(info);
+    }
+
+    /**
+     * Must be called when {@link InputMethodService#updateFullscreenMode} is called.
+     * @param isFullscreen {@code true} if the input method is in full-screen mode.
+     */
+    public void onUpdateFullscreenMode(final boolean isFullscreen) {
+        mTextDecorator.notifyFullScreenMode(isFullscreen);
+    }
+
+    /**
+     * Must be called from {@link LatinIME#addWordToUserDictionary(String)}.
+     */
+    public void onAddWordToUserDictionary() {
+        mConnection.removeBackgroundColorFromHighlightedTextIfNecessary();
+        mTextDecorator.reset();
+    }
+
+    /**
+     * Returns whether the commit indicator should be shown or not.
+     * @param suggestedWords the suggested word that is being displayed.
+     * @param settingsValues the current settings value.
+     * @return {@code true} if the commit indicator should be shown.
+     */
+    private boolean shouldShowCommitIndicator(final SuggestedWords suggestedWords,
+            final SettingsValues settingsValues) {
+        if (!settingsValues.mShouldShowUiToAcceptTypedWord) {
+            return false;
+        }
+        final SuggestedWordInfo typedWordInfo = suggestedWords.getTypedWordInfoOrNull();
+        if (typedWordInfo == null) {
+            return false;
+        }
+        if (suggestedWords.mInputStyle != SuggestedWords.INPUT_STYLE_TYPING){
+            return false;
+        }
+        if (settingsValues.mShowCommitIndicatorOnlyForAutoCorrection
+                && !suggestedWords.mWillAutoCorrect) {
+            return false;
+        }
+        // TODO: Calling shouldShowAddToDictionaryHint(typedWordInfo) multiple times should be fine
+        // in terms of performance, but we can do better. One idea is to make SuggestedWords include
+        // a boolean that tells whether the word is a dictionary word or not.
+        if (settingsValues.mShowCommitIndicatorOnlyForOutOfVocabulary
+                && !shouldShowAddToDictionaryHint(typedWordInfo)) {
+            return false;
+        }
+        return true;
+    }
 }
diff --git a/java/src/com/android/inputmethod/latin/settings/SettingsValues.java b/java/src/com/android/inputmethod/latin/settings/SettingsValues.java
index 1cd7b39..dc2eda9 100644
--- a/java/src/com/android/inputmethod/latin/settings/SettingsValues.java
+++ b/java/src/com/android/inputmethod/latin/settings/SettingsValues.java
@@ -95,6 +95,12 @@
     public final int[] mAdditionalFeaturesSettingValues =
             new int[AdditionalFeaturesSettingUtils.ADDITIONAL_FEATURES_SETTINGS_SIZE];
 
+    // TextDecorator
+    public final int mTextHighlightColorForCommitIndicator;
+    public final int mTextHighlightColorForAddToDictionaryIndicator;
+    public final boolean mShowCommitIndicatorOnlyForAutoCorrection;
+    public final boolean mShowCommitIndicatorOnlyForOutOfVocabulary;
+
     // Debug settings
     public final boolean mIsInternal;
     public final int mKeyPreviewShowUpDuration;
@@ -163,6 +169,14 @@
         mSuggestionsEnabledPerUserSettings = readSuggestionsEnabled(prefs);
         AdditionalFeaturesSettingUtils.readAdditionalFeaturesPreferencesIntoArray(
                 prefs, mAdditionalFeaturesSettingValues);
+        mShowCommitIndicatorOnlyForAutoCorrection = res.getBoolean(
+                R.bool.text_decorator_only_for_auto_correction);
+        mShowCommitIndicatorOnlyForOutOfVocabulary = res.getBoolean(
+                R.bool.text_decorator_only_for_out_of_vocabulary);
+        mTextHighlightColorForCommitIndicator = res.getColor(
+                R.color.text_decorator_commit_indicator_text_highlight_color);
+        mTextHighlightColorForAddToDictionaryIndicator = res.getColor(
+                R.color.text_decorator_add_to_dictionary_indicator_text_highlight_color);
         mIsInternal = Settings.isInternal(prefs);
         mKeyPreviewShowUpDuration = Settings.readKeyPreviewAnimationDuration(
                 prefs, DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_DURATION,
@@ -396,6 +410,14 @@
         sb.append("" + (null == awu ? "null" : awu.toString()));
         sb.append("\n   mAdditionalFeaturesSettingValues = ");
         sb.append("" + Arrays.toString(mAdditionalFeaturesSettingValues));
+        sb.append("\n   mShowCommitIndicatorOnlyForAutoCorrection = ");
+        sb.append("" + mShowCommitIndicatorOnlyForAutoCorrection);
+        sb.append("\n   mShowCommitIndicatorOnlyForOutOfVocabulary = ");
+        sb.append("" + mShowCommitIndicatorOnlyForOutOfVocabulary);
+        sb.append("\n   mTextHighlightColorForCommitIndicator = ");
+        sb.append("" + mTextHighlightColorForCommitIndicator);
+        sb.append("\n   mTextHighlightColorForAddToDictionaryIndicator = ");
+        sb.append("" + mTextHighlightColorForAddToDictionaryIndicator);
         sb.append("\n   mIsInternal = ");
         sb.append("" + mIsInternal);
         sb.append("\n   mKeyPreviewShowUpDuration = ");