blob: 9a0441c3d65a1168d30aa7713d7eb41b0044b21f [file] [log] [blame]
/*
* Copyright (C) 2009 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.pinyin;
import com.android.inputmethod.pinyin.PinyinIME.DecodingInfo;
import java.util.Vector;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Paint.FontMetricsInt;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
/**
* View to show candidate list. There two candidate view instances which are
* used to show animation when user navigates between pages.
*/
public class CandidateView extends View {
/**
* The minimum width to show a item.
*/
private static final float MIN_ITEM_WIDTH = 22;
/**
* Suspension points used to display long items.
*/
private static final String SUSPENSION_POINTS = "...";
/**
* The width to draw candidates.
*/
private int mContentWidth;
/**
* The height to draw candidate content.
*/
private int mContentHeight;
/**
* Whether footnotes are displayed. Footnote is shown when hardware keyboard
* is available.
*/
private boolean mShowFootnote = true;
/**
* Balloon hint for candidate press/release.
*/
private BalloonHint mBalloonHint;
/**
* Desired position of the balloon to the input view.
*/
private int mHintPositionToInputView[] = new int[2];
/**
* Decoding result to show.
*/
private DecodingInfo mDecInfo;
/**
* Listener used to notify IME that user clicks a candidate, or navigate
* between them.
*/
private CandidateViewListener mCvListener;
/**
* Used to notify the container to update the status of forward/backward
* arrows.
*/
private ArrowUpdater mArrowUpdater;
/**
* If true, update the arrow status when drawing candidates.
*/
private boolean mUpdateArrowStatusWhenDraw = false;
/**
* Page number of the page displayed in this view.
*/
private int mPageNo;
/**
* Active candidate position in this page.
*/
private int mActiveCandInPage;
/**
* Used to decided whether the active candidate should be highlighted or
* not. If user changes focus to composing view (The view to show Pinyin
* string), the highlight in candidate view should be removed.
*/
private boolean mEnableActiveHighlight = true;
/**
* The page which is just calculated.
*/
private int mPageNoCalculated = -1;
/**
* The Drawable used to display as the background of the high-lighted item.
*/
private Drawable mActiveCellDrawable;
/**
* The Drawable used to display as separators between candidates.
*/
private Drawable mSeparatorDrawable;
/**
* Color to draw normal candidates generated by IME.
*/
private int mImeCandidateColor;
/**
* Color to draw normal candidates Recommended by application.
*/
private int mRecommendedCandidateColor;
/**
* Color to draw the normal(not highlighted) candidates, it can be one of
* {@link #mImeCandidateColor} or {@link #mRecommendedCandidateColor}.
*/
private int mNormalCandidateColor;
/**
* Color to draw the active(highlighted) candidates, including candidates
* from IME and candidates from application.
*/
private int mActiveCandidateColor;
/**
* Text size to draw candidates generated by IME.
*/
private int mImeCandidateTextSize;
/**
* Text size to draw candidates recommended by application.
*/
private int mRecommendedCandidateTextSize;
/**
* The current text size to draw candidates. It can be one of
* {@link #mImeCandidateTextSize} or {@link #mRecommendedCandidateTextSize}.
*/
private int mCandidateTextSize;
/**
* Paint used to draw candidates.
*/
private Paint mCandidatesPaint;
/**
* Used to draw footnote.
*/
private Paint mFootnotePaint;
/**
* The width to show suspension points.
*/
private float mSuspensionPointsWidth;
/**
* Rectangle used to draw the active candidate.
*/
private RectF mActiveCellRect;
/**
* Left and right margins for a candidate. It is specified in xml, and is
* the minimum margin for a candidate. The actual gap between two candidates
* is 2 * {@link #mCandidateMargin} + {@link #mSeparatorDrawable}.
* getIntrinsicWidth(). Because length of candidate is not fixed, there can
* be some extra space after the last candidate in the current page. In
* order to achieve best look-and-feel, this extra space will be divided and
* allocated to each candidates.
*/
private float mCandidateMargin;
/**
* Left and right extra margins for a candidate.
*/
private float mCandidateMarginExtra;
/**
* Rectangles for the candidates in this page.
**/
private Vector<RectF> mCandRects;
/**
* FontMetricsInt used to measure the size of candidates.
*/
private FontMetricsInt mFmiCandidates;
/**
* FontMetricsInt used to measure the size of footnotes.
*/
private FontMetricsInt mFmiFootnote;
private PressTimer mTimer = new PressTimer();
private GestureDetector mGestureDetector;
private int mLocationTmp[] = new int[2];
public CandidateView(Context context, AttributeSet attrs) {
super(context, attrs);
Resources r = context.getResources();
Configuration conf = r.getConfiguration();
if (conf.keyboard == Configuration.KEYBOARD_NOKEYS
|| conf.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES) {
mShowFootnote = false;
}
mActiveCellDrawable = r.getDrawable(R.drawable.candidate_hl_bg);
mSeparatorDrawable = r.getDrawable(R.drawable.candidates_vertical_line);
mCandidateMargin = r.getDimension(R.dimen.candidate_margin_left_right);
mImeCandidateColor = r.getColor(R.color.candidate_color);
mRecommendedCandidateColor = r.getColor(R.color.recommended_candidate_color);
mNormalCandidateColor = mImeCandidateColor;
mActiveCandidateColor = r.getColor(R.color.active_candidate_color);
mCandidatesPaint = new Paint();
mCandidatesPaint.setAntiAlias(true);
mFootnotePaint = new Paint();
mFootnotePaint.setAntiAlias(true);
mFootnotePaint.setColor(r.getColor(R.color.footnote_color));
mActiveCellRect = new RectF();
mCandRects = new Vector<RectF>();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int mOldWidth = getMeasuredWidth();
int mOldHeight = getMeasuredHeight();
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(),
widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(),
heightMeasureSpec));
if (mOldWidth != getMeasuredWidth() || mOldHeight != getMeasuredHeight()) {
onSizeChanged();
}
}
public void initialize(ArrowUpdater arrowUpdater, BalloonHint balloonHint,
GestureDetector gestureDetector, CandidateViewListener cvListener) {
mArrowUpdater = arrowUpdater;
mBalloonHint = balloonHint;
mGestureDetector = gestureDetector;
mCvListener = cvListener;
}
public void setDecodingInfo(DecodingInfo decInfo) {
if (null == decInfo) return;
mDecInfo = decInfo;
mPageNoCalculated = -1;
if (mDecInfo.candidatesFromApp()) {
mNormalCandidateColor = mRecommendedCandidateColor;
mCandidateTextSize = mRecommendedCandidateTextSize;
} else {
mNormalCandidateColor = mImeCandidateColor;
mCandidateTextSize = mImeCandidateTextSize;
}
if (mCandidatesPaint.getTextSize() != mCandidateTextSize) {
mCandidatesPaint.setTextSize(mCandidateTextSize);
mFmiCandidates = mCandidatesPaint.getFontMetricsInt();
mSuspensionPointsWidth =
mCandidatesPaint.measureText(SUSPENSION_POINTS);
}
// Remove any pending timer for the previous list.
mTimer.removeTimer();
}
public int getActiveCandiatePosInPage() {
return mActiveCandInPage;
}
public int getActiveCandiatePosGlobal() {
return mDecInfo.mPageStart.get(mPageNo) + mActiveCandInPage;
}
/**
* Show a page in the decoding result set previously.
*
* @param pageNo Which page to show.
* @param activeCandInPage Which candidate should be set as active item.
* @param enableActiveHighlight When false, active item will not be
* highlighted.
*/
public void showPage(int pageNo, int activeCandInPage,
boolean enableActiveHighlight) {
if (null == mDecInfo) return;
mPageNo = pageNo;
mActiveCandInPage = activeCandInPage;
if (mEnableActiveHighlight != enableActiveHighlight) {
mEnableActiveHighlight = enableActiveHighlight;
}
if (!calculatePage(mPageNo)) {
mUpdateArrowStatusWhenDraw = true;
} else {
mUpdateArrowStatusWhenDraw = false;
}
invalidate();
}
public void enableActiveHighlight(boolean enableActiveHighlight) {
if (enableActiveHighlight == mEnableActiveHighlight) return;
mEnableActiveHighlight = enableActiveHighlight;
invalidate();
}
public boolean activeCursorForward() {
if (!mDecInfo.pageReady(mPageNo)) return false;
int pageSize = mDecInfo.mPageStart.get(mPageNo + 1)
- mDecInfo.mPageStart.get(mPageNo);
if (mActiveCandInPage + 1 < pageSize) {
showPage(mPageNo, mActiveCandInPage + 1, true);
return true;
}
return false;
}
public boolean activeCurseBackward() {
if (mActiveCandInPage > 0) {
showPage(mPageNo, mActiveCandInPage - 1, true);
return true;
}
return false;
}
private void onSizeChanged() {
mContentWidth = getMeasuredWidth() - mPaddingLeft - mPaddingRight;
mContentHeight = (int) ((getMeasuredHeight() - mPaddingTop - mPaddingBottom) * 0.95f);
/**
* How to decide the font size if the height for display is given?
* Now it is implemented in a stupid way.
*/
int textSize = 1;
mCandidatesPaint.setTextSize(textSize);
mFmiCandidates = mCandidatesPaint.getFontMetricsInt();
while (mFmiCandidates.bottom - mFmiCandidates.top < mContentHeight) {
textSize++;
mCandidatesPaint.setTextSize(textSize);
mFmiCandidates = mCandidatesPaint.getFontMetricsInt();
}
mImeCandidateTextSize = textSize;
mRecommendedCandidateTextSize = textSize * 3 / 4;
if (null == mDecInfo) {
mCandidateTextSize = mImeCandidateTextSize;
mCandidatesPaint.setTextSize(mCandidateTextSize);
mFmiCandidates = mCandidatesPaint.getFontMetricsInt();
mSuspensionPointsWidth =
mCandidatesPaint.measureText(SUSPENSION_POINTS);
} else {
// Reset the decoding information to update members for painting.
setDecodingInfo(mDecInfo);
}
textSize = 1;
mFootnotePaint.setTextSize(textSize);
mFmiFootnote = mFootnotePaint.getFontMetricsInt();
while (mFmiFootnote.bottom - mFmiFootnote.top < mContentHeight / 2) {
textSize++;
mFootnotePaint.setTextSize(textSize);
mFmiFootnote = mFootnotePaint.getFontMetricsInt();
}
textSize--;
mFootnotePaint.setTextSize(textSize);
mFmiFootnote = mFootnotePaint.getFontMetricsInt();
// When the size is changed, the first page will be displayed.
mPageNo = 0;
mActiveCandInPage = 0;
}
private boolean calculatePage(int pageNo) {
if (pageNo == mPageNoCalculated) return true;
mContentWidth = getMeasuredWidth() - mPaddingLeft - mPaddingRight;
mContentHeight = (int) ((getMeasuredHeight() - mPaddingTop - mPaddingBottom) * 0.95f);
if (mContentWidth <= 0 || mContentHeight <= 0) return false;
int candSize = mDecInfo.mCandidatesList.size();
// If the size of page exists, only calculate the extra margin.
boolean onlyExtraMargin = false;
int fromPage = mDecInfo.mPageStart.size() - 1;
if (mDecInfo.mPageStart.size() > pageNo + 1) {
onlyExtraMargin = true;
fromPage = pageNo;
}
// If the previous pages have no information, calculate them first.
for (int p = fromPage; p <= pageNo; p++) {
int pStart = mDecInfo.mPageStart.get(p);
int pSize = 0;
int charNum = 0;
float lastItemWidth = 0;
float xPos;
xPos = 0;
xPos += mSeparatorDrawable.getIntrinsicWidth();
while (xPos < mContentWidth && pStart + pSize < candSize) {
int itemPos = pStart + pSize;
String itemStr = mDecInfo.mCandidatesList.get(itemPos);
float itemWidth = mCandidatesPaint.measureText(itemStr);
if (itemWidth < MIN_ITEM_WIDTH) itemWidth = MIN_ITEM_WIDTH;
itemWidth += mCandidateMargin * 2;
itemWidth += mSeparatorDrawable.getIntrinsicWidth();
if (xPos + itemWidth < mContentWidth || 0 == pSize) {
xPos += itemWidth;
lastItemWidth = itemWidth;
pSize++;
charNum += itemStr.length();
} else {
break;
}
}
if (!onlyExtraMargin) {
mDecInfo.mPageStart.add(pStart + pSize);
mDecInfo.mCnToPage.add(mDecInfo.mCnToPage.get(p) + charNum);
}
float marginExtra = (mContentWidth - xPos) / pSize / 2;
if (mContentWidth - xPos > lastItemWidth) {
// Must be the last page, because if there are more items,
// the next item's width must be less than lastItemWidth.
// In this case, if the last margin is less than the current
// one, the last margin can be used, so that the
// look-and-feeling will be the same as the previous page.
if (mCandidateMarginExtra <= marginExtra) {
marginExtra = mCandidateMarginExtra;
}
} else if (pSize == 1) {
marginExtra = 0;
}
mCandidateMarginExtra = marginExtra;
}
mPageNoCalculated = pageNo;
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// The invisible candidate view(the one which is not in foreground) can
// also be called to drawn, but its decoding result and candidate list
// may be empty.
if (null == mDecInfo || mDecInfo.isCandidatesListEmpty()) return;
// Calculate page. If the paging information is ready, the function will
// return at once.
calculatePage(mPageNo);
int pStart = mDecInfo.mPageStart.get(mPageNo);
int pSize = mDecInfo.mPageStart.get(mPageNo + 1) - pStart;
float candMargin = mCandidateMargin + mCandidateMarginExtra;
if (mActiveCandInPage > pSize - 1) {
mActiveCandInPage = pSize - 1;
}
mCandRects.removeAllElements();
float xPos = mPaddingLeft;
int yPos = (getMeasuredHeight() -
(mFmiCandidates.bottom - mFmiCandidates.top)) / 2
- mFmiCandidates.top;
xPos += drawVerticalSeparator(canvas, xPos);
for (int i = 0; i < pSize; i++) {
float footnoteSize = 0;
String footnote = null;
if (mShowFootnote) {
footnote = Integer.toString(i + 1);
footnoteSize = mFootnotePaint.measureText(footnote);
assert (footnoteSize < candMargin);
}
String cand = mDecInfo.mCandidatesList.get(pStart + i);
float candidateWidth = mCandidatesPaint.measureText(cand);
float centerOffset = 0;
if (candidateWidth < MIN_ITEM_WIDTH) {
centerOffset = (MIN_ITEM_WIDTH - candidateWidth) / 2;
candidateWidth = MIN_ITEM_WIDTH;
}
float itemTotalWidth = candidateWidth + 2 * candMargin;
if (mActiveCandInPage == i && mEnableActiveHighlight) {
mActiveCellRect.set(xPos, mPaddingTop + 1, xPos
+ itemTotalWidth, getHeight() - mPaddingBottom - 1);
mActiveCellDrawable.setBounds((int) mActiveCellRect.left,
(int) mActiveCellRect.top, (int) mActiveCellRect.right,
(int) mActiveCellRect.bottom);
mActiveCellDrawable.draw(canvas);
}
if (mCandRects.size() < pSize) mCandRects.add(new RectF());
mCandRects.elementAt(i).set(xPos - 1, yPos + mFmiCandidates.top,
xPos + itemTotalWidth + 1, yPos + mFmiCandidates.bottom);
// Draw footnote
if (mShowFootnote) {
canvas.drawText(footnote, xPos + (candMargin - footnoteSize)
/ 2, yPos, mFootnotePaint);
}
// Left margin
xPos += candMargin;
if (candidateWidth > mContentWidth - xPos - centerOffset) {
cand = getLimitedCandidateForDrawing(cand,
mContentWidth - xPos - centerOffset);
}
if (mActiveCandInPage == i && mEnableActiveHighlight) {
mCandidatesPaint.setColor(mActiveCandidateColor);
} else {
mCandidatesPaint.setColor(mNormalCandidateColor);
}
canvas.drawText(cand, xPos + centerOffset, yPos,
mCandidatesPaint);
// Candidate and right margin
xPos += candidateWidth + candMargin;
// Draw the separator between candidates.
xPos += drawVerticalSeparator(canvas, xPos);
}
// Update the arrow status of the container.
if (null != mArrowUpdater && mUpdateArrowStatusWhenDraw) {
mArrowUpdater.updateArrowStatus();
mUpdateArrowStatusWhenDraw = false;
}
}
private String getLimitedCandidateForDrawing(String rawCandidate,
float widthToDraw) {
int subLen = rawCandidate.length();
if (subLen <= 1) return rawCandidate;
do {
subLen--;
float width = mCandidatesPaint.measureText(rawCandidate, 0, subLen);
if (width + mSuspensionPointsWidth <= widthToDraw || 1 >= subLen) {
return rawCandidate.substring(0, subLen) +
SUSPENSION_POINTS;
}
} while (true);
}
private float drawVerticalSeparator(Canvas canvas, float xPos) {
mSeparatorDrawable.setBounds((int) xPos, mPaddingTop, (int) xPos
+ mSeparatorDrawable.getIntrinsicWidth(), getMeasuredHeight()
- mPaddingBottom);
mSeparatorDrawable.draw(canvas);
return mSeparatorDrawable.getIntrinsicWidth();
}
private int mapToItemInPage(int x, int y) {
// mCandRects.size() == 0 happens when the page is set, but
// touch events occur before onDraw(). It usually happens with
// monkey test.
if (!mDecInfo.pageReady(mPageNo) || mPageNoCalculated != mPageNo
|| mCandRects.size() == 0) {
return -1;
}
int pageStart = mDecInfo.mPageStart.get(mPageNo);
int pageSize = mDecInfo.mPageStart.get(mPageNo + 1) - pageStart;
if (mCandRects.size() < pageSize) {
return -1;
}
// If not found, try to find the nearest one.
float nearestDis = Float.MAX_VALUE;
int nearest = -1;
for (int i = 0; i < pageSize; i++) {
RectF r = mCandRects.elementAt(i);
if (r.left < x && r.right > x && r.top < y && r.bottom > y) {
return i;
}
float disx = (r.left + r.right) / 2 - x;
float disy = (r.top + r.bottom) / 2 - y;
float dis = disx * disx + disy * disy;
if (dis < nearestDis) {
nearestDis = dis;
nearest = i;
}
}
return nearest;
}
// Because the candidate view under the current focused one may also get
// touching events. Here we just bypass the event to the container and let
// it decide which view should handle the event.
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
public boolean onTouchEventReal(MotionEvent event) {
// The page in the background can also be touched.
if (null == mDecInfo || !mDecInfo.pageReady(mPageNo)
|| mPageNoCalculated != mPageNo) return true;
int x, y;
x = (int) event.getX();
y = (int) event.getY();
if (mGestureDetector.onTouchEvent(event)) {
mTimer.removeTimer();
mBalloonHint.delayedDismiss(0);
return true;
}
int clickedItemInPage = -1;
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
clickedItemInPage = mapToItemInPage(x, y);
if (clickedItemInPage >= 0) {
invalidate();
mCvListener.onClickChoice(clickedItemInPage
+ mDecInfo.mPageStart.get(mPageNo));
}
mBalloonHint.delayedDismiss(BalloonHint.TIME_DELAY_DISMISS);
break;
case MotionEvent.ACTION_DOWN:
clickedItemInPage = mapToItemInPage(x, y);
if (clickedItemInPage >= 0) {
showBalloon(clickedItemInPage, true);
mTimer.startTimer(BalloonHint.TIME_DELAY_SHOW, mPageNo,
clickedItemInPage);
}
break;
case MotionEvent.ACTION_CANCEL:
break;
case MotionEvent.ACTION_MOVE:
clickedItemInPage = mapToItemInPage(x, y);
if (clickedItemInPage >= 0
&& (clickedItemInPage != mTimer.getActiveCandOfPageToShow() || mPageNo != mTimer
.getPageToShow())) {
showBalloon(clickedItemInPage, true);
mTimer.startTimer(BalloonHint.TIME_DELAY_SHOW, mPageNo,
clickedItemInPage);
}
}
return true;
}
private void showBalloon(int candPos, boolean delayedShow) {
mBalloonHint.removeTimer();
RectF r = mCandRects.elementAt(candPos);
int desired_width = (int) (r.right - r.left);
int desired_height = (int) (r.bottom - r.top);
mBalloonHint.setBalloonConfig(mDecInfo.mCandidatesList
.get(mDecInfo.mPageStart.get(mPageNo) + candPos), 44, true,
mImeCandidateColor, desired_width, desired_height);
getLocationOnScreen(mLocationTmp);
mHintPositionToInputView[0] = mLocationTmp[0]
+ (int) (r.left - (mBalloonHint.getWidth() - desired_width) / 2);
mHintPositionToInputView[1] = -mBalloonHint.getHeight();
long delay = BalloonHint.TIME_DELAY_SHOW;
if (!delayedShow) delay = 0;
mBalloonHint.dismiss();
if (!mBalloonHint.isShowing()) {
mBalloonHint.delayedShow(delay, mHintPositionToInputView);
} else {
mBalloonHint.delayedUpdate(0, mHintPositionToInputView, -1, -1);
}
}
private class PressTimer extends Handler implements Runnable {
private boolean mTimerPending = false;
private int mPageNoToShow;
private int mActiveCandOfPage;
public PressTimer() {
super();
}
public void startTimer(long afterMillis, int pageNo, int activeInPage) {
mTimer.removeTimer();
postDelayed(this, afterMillis);
mTimerPending = true;
mPageNoToShow = pageNo;
mActiveCandOfPage = activeInPage;
}
public int getPageToShow() {
return mPageNoToShow;
}
public int getActiveCandOfPageToShow() {
return mActiveCandOfPage;
}
public boolean removeTimer() {
if (mTimerPending) {
mTimerPending = false;
removeCallbacks(this);
return true;
}
return false;
}
public boolean isPending() {
return mTimerPending;
}
public void run() {
if (mPageNoToShow >= 0 && mActiveCandOfPage >= 0) {
// Always enable to highlight the clicked one.
showPage(mPageNoToShow, mActiveCandOfPage, true);
invalidate();
}
mTimerPending = false;
}
}
}