/*
* 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.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 javax.annotation.Nonnull;
import org.smc.inputmethod.annotations.UsedForTesting;
import org.smc.inputmethod.compat.CursorAnchorInfoCompatWrapper;
import com.android.inputmethod.latin.utils.LeakGuardHandlerWrapper;
/**
* A controller class of the 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 INVALID_CURSOR_INDEX = -1;
private static final int MODE_MONITOR = 0;
private static final int MODE_WAITING_CURSOR_INDEX = 1;
private static final int MODE_SHOWING_INDICATOR = 2;
private int mMode = MODE_MONITOR;
private String mLastComposingText = null;
private boolean mHasRtlCharsInLastComposingText = false;
private RectF mComposingTextBoundsForLastComposingText = new RectF();
private boolean mIsFullScreenMode = false;
private String mWaitingWord = null;
private int mWaitingCursorStart = INVALID_CURSOR_INDEX;
private int mWaitingCursorEnd = INVALID_CURSOR_INDEX;
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 indicator to add the word into the dictionary.
* @param word the word which the user clicked on.
*/
void onClickComposingTextToAddToDictionary(final String word);
}
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 "Add to dictionary" indicator and associates it with associating the given word.
*
* @param word the word which should be associated with the indicator. This object will be
* passed back in {@link Listener#onClickComposingTextToAddToDictionary(String)}.
* @param selectionStart the cursor index (inclusive) when the indicator should be displayed.
* @param selectionEnd the cursor index (exclusive) when the indicator should be displayed.
*/
public void showAddToDictionaryIndicator(final String word, final int selectionStart,
final int selectionEnd) {
mWaitingWord = word;
mWaitingCursorStart = selectionStart;
mWaitingCursorEnd = selectionEnd;
mMode = MODE_WAITING_CURSOR_INDEX;
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 fullScreenModeChanged = (mIsFullScreenMode != fullScreenMode);
mIsFullScreenMode = fullScreenMode;
if (fullScreenModeChanged) {
layoutLater();
}
}
/**
* Resets previous requests and makes indicator invisible.
*/
public void reset() {
mWaitingWord = null;
mMode = MODE_MONITOR;
mWaitingCursorStart = INVALID_CURSOR_INDEX;
mWaitingCursorEnd = INVALID_CURSOR_INDEX;
cancelLayoutInternalExpectedly("Resetting internal state.");
}
/**
* Must be called when the {@link InputMethodService#onUpdateCursorAnchorInfo(CursorAnchorInfo)}
* is called.
*
* <p>CAVEAT: Currently the input method author is responsible for ignoring
* {@link InputMethodService#onUpdateCursorAnchorInfo(CursorAnchorInfo)} called in full screen
* mode.</p>
* @param info the compatibility wrapper object for the received {@link CursorAnchorInfo}.
*/
public void onUpdateCursorAnchorInfo(final CursorAnchorInfoCompatWrapper info) {
mCursorAnchorInfoWrapper = info;
// Do not use layoutLater() to minimize the latency.
layoutImmediately();
}
private void cancelLayoutInternalUnexpectedly(final String message) {
mUiOperator.hideUi();
Log.d(TAG, message);
}
private void cancelLayoutInternalExpectedly(final String message) {
mUiOperator.hideUi();
if (DEBUG) {
Log.d(TAG, message);
}
}
private void layoutLater() {
mLayoutInvalidator.invalidateLayout();
}
private void layoutImmediately() {
// Clear pending layout requests.
mLayoutInvalidator.cancelInvalidateLayout();
layoutMain();
}
private void layoutMain() {
final CursorAnchorInfoCompatWrapper info = mCursorAnchorInfoWrapper;
if (info == null || !info.isAvailable()) {
cancelLayoutInternalExpectedly("CursorAnchorInfo isn't available.");
return;
}
final Matrix matrix = info.getMatrix();
if (matrix == null) {
cancelLayoutInternalUnexpectedly("Matrix is null");
}
final CharSequence composingText = info.getComposingText();
if (!TextUtils.isEmpty(composingText)) {
final int composingTextStart = info.getComposingTextStart();
final int lastCharRectIndex = composingTextStart + composingText.length() - 1;
final RectF lastCharRect = info.getCharacterBounds(lastCharRectIndex);
final int lastCharRectFlags = info.getCharacterBoundsFlags(lastCharRectIndex);
final boolean hasInvisibleRegionInLastCharRect =
(lastCharRectFlags & CursorAnchorInfoCompatWrapper.FLAG_HAS_INVISIBLE_REGION)
!= 0;
if (lastCharRect == null || matrix == null || hasInvisibleRegionInLastCharRect) {
mUiOperator.hideUi();
return;
}
// Note that the following layout information is fragile, and must be invalidated
// even when surrounding text next to the composing text is changed because it can
// affect how the composing text is rendered.
// TODO: Investigate if we can change the input logic to make the target text
// composing state so that we can retrieve the character bounds reliably.
final String composingTextString = composingText.toString();
final float top = lastCharRect.top;
final float bottom = lastCharRect.bottom;
float left = lastCharRect.left;
float right = lastCharRect.right;
boolean useRtlLayout = false;
for (int i = composingText.length() - 1; i >= 0; --i) {
final int characterIndex = composingTextStart + i;
final RectF characterBounds = info.getCharacterBounds(characterIndex);
final int characterBoundsFlags = info.getCharacterBoundsFlags(characterIndex);
if (characterBounds == null) {
break;
}
if (characterBounds.top != top) {
break;
}
if (characterBounds.bottom != bottom) {
break;
}
if ((characterBoundsFlags & CursorAnchorInfoCompatWrapper.FLAG_IS_RTL) != 0) {
// This is for both RTL text and bi-directional text. RTL languages usually mix
// RTL characters with LTR characters and in this case we should display the
// indicator on the left, while in LTR languages that normally never happens.
// TODO: Try to come up with a better algorithm.
useRtlLayout = true;
}
left = Math.min(characterBounds.left, left);
right = Math.max(characterBounds.right, right);
}
mLastComposingText = composingTextString;
mHasRtlCharsInLastComposingText = useRtlLayout;
mComposingTextBoundsForLastComposingText.set(left, top, right, bottom);
}
final int selectionStart = info.getSelectionStart();
final int selectionEnd = info.getSelectionEnd();
switch (mMode) {
case MODE_MONITOR:
mUiOperator.hideUi();
return;
case MODE_WAITING_CURSOR_INDEX:
if (selectionStart != mWaitingCursorStart || selectionEnd != mWaitingCursorEnd) {
mUiOperator.hideUi();
return;
}
mMode = MODE_SHOWING_INDICATOR;
break;
case MODE_SHOWING_INDICATOR:
if (selectionStart != mWaitingCursorStart || selectionEnd != mWaitingCursorEnd) {
mUiOperator.hideUi();
mMode = MODE_MONITOR;
mWaitingCursorStart = INVALID_CURSOR_INDEX;
mWaitingCursorEnd = INVALID_CURSOR_INDEX;
return;
}
break;
default:
cancelLayoutInternalUnexpectedly("Unexpected internal mode=" + mMode);
return;
}
if (!TextUtils.equals(mLastComposingText, mWaitingWord)) {
cancelLayoutInternalUnexpectedly("mLastComposingText doesn't match mWaitingWord");
return;
}
if ((info.getInsertionMarkerFlags() &
CursorAnchorInfoCompatWrapper.FLAG_HAS_INVISIBLE_REGION) != 0) {
mUiOperator.hideUi();
return;
}
mUiOperator.layoutUi(matrix, mComposingTextBoundsForLastComposingText,
mHasRtlCharsInLastComposingText);
}
private void onClickIndicator() {
if (mMode != MODE_SHOWING_INDICATOR) {
return;
}
mListener.onClickComposingTextToAddToDictionary(mWaitingWord);
}
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 onClickComposingTextToAddToDictionary(final String word) {
}
};
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(Matrix matrix, RectF composingTextBounds, boolean useRtlLayout) {
}
};
}