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