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