blob: 97048db719320aa1b3118c08034f1ef9722f8a92 [file] [log] [blame]
// Copyright 2010-2014, Google Inc.
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package org.mozc.android.inputmethod.japanese;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.Category;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.Command;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.CompositionMode;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.Output;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.Preedit.Segment;
import org.mozc.android.inputmethod.japanese.ui.FloatingCandidateLayoutRenderer;
import org.mozc.android.inputmethod.japanese.ui.FloatingModeIndicator;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.text.InputType;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.EditorInfo;
import android.widget.PopupWindow;
/**
* Floating candidate view for hardware keyboard.
*/
@TargetApi(21)
public class FloatingCandidateView extends View {
private interface FloatingCandidateViewProxy {
public void draw(Canvas canvas);
public void viewSizeChanged(int width, int height);
public void setCursorAnchorInfo(CursorAnchorInfo info);
public void setCandidates(Command outCommand);
public void setEditorInfo(EditorInfo editorInfo);
public void setCompositionMode(CompositionMode mode);
public void setViewEventListener(ViewEventListener listener);
public void setVisibility(int visibility);
public Optional<Rect> getVisibleRect();
}
private static class FloatingCandidateViewStub implements FloatingCandidateViewProxy {
@Override
public void draw(Canvas canvas) {}
@Override
public void viewSizeChanged(int width, int height) {}
@Override
public void setCursorAnchorInfo(CursorAnchorInfo info) {}
@Override
public void setCandidates(Command outCommand) {}
@Override
public void setEditorInfo(EditorInfo editorInfo) {}
@Override
public void setCompositionMode(CompositionMode mode) {}
@Override
public void setViewEventListener(ViewEventListener listener) {}
@Override
public void setVisibility(int visibility) {}
@Override
public Optional<Rect> getVisibleRect() {
return Optional.absent();
}
}
@TargetApi(21)
private static class FloatingCandidateViewImpl implements FloatingCandidateViewProxy {
private final View parentView;
/** Layouts the floating candidate window and draws it's contents. */
private final FloatingCandidateLayoutRenderer layoutRenderer;
private final FloatingModeIndicator modeIndicator;
/**
* Pop-up window to handle touch events.
* <p>
* A touch down event on outside a touchable region (set by {@link MozcService#onComputeInsets})
* cannot be caught by view, and we cannot expand the touchable region since all touch down
* events inside the region are not delegated to a background application.
* To handle these touch events, we employ pop-up window.
* <p>
* This window is always invisible since we cannot control the transition behavior.
* (e.g. Pop-up window always move with animation)
*/
private final PopupWindow touchEventReceiverWindow;
private final int windowVerticalMargin;
private final int windowHorizontalMargin;
/**
* Base position of the floating candidate window.
* <p>
* It is same as the cursor rectangle on pre-composition state, and the left-edge of the focused
* segment on other states.
*/
private int basePositionTop;
private int basePositionBottom;
private int basePositionX;
private Optional<CursorAnchorInfo> cursorAnchorInfo = Optional.absent();
private Category candidatesCategory = Category.CONVERSION;
private int highlightedCharacterStart;
private int compositionCharacterEnd;
/** True if EditorInfo says suggestion should be suppressed. */
private boolean suppressSuggestion;
/**
* Horizontal offset of the candidate window. See also {@link FloatingCandidateLayoutRenderer}
*/
private int offsetX;
/** Vertical offset of the candidate window. See also {@link FloatingCandidateLayoutRenderer} */
private int offsetY;
private boolean isCandidateWindowShowing;
public FloatingCandidateViewImpl(View parentView) {
Context context = Preconditions.checkNotNull(parentView).getContext();
this.parentView = parentView;
this.layoutRenderer = new FloatingCandidateLayoutRenderer(context.getResources());
this.modeIndicator = new FloatingModeIndicator(parentView);
this.touchEventReceiverWindow = createPopupWindow(context);
Resources resources = context.getResources();
this.windowVerticalMargin =
Math.round(resources.getDimension(R.dimen.floating_candidate_window_vertical_margin));
this.windowHorizontalMargin =
Math.round(resources.getDimension(R.dimen.floating_candidate_window_horizontal_margin));
}
public FloatingCandidateViewImpl(View parentView, PopupWindow popupWindowMock,
FloatingCandidateLayoutRenderer layoutRenderer,
FloatingModeIndicator modeIndicator) {
this.parentView = Preconditions.checkNotNull(parentView);
this.layoutRenderer = Preconditions.checkNotNull(layoutRenderer);
this.modeIndicator = Preconditions.checkNotNull(modeIndicator);
this.touchEventReceiverWindow = Preconditions.checkNotNull(popupWindowMock);
Resources resources = parentView.getContext().getResources();
this.windowVerticalMargin =
resources.getDimensionPixelSize(R.dimen.floating_candidate_window_vertical_margin);
this.windowHorizontalMargin =
resources.getDimensionPixelSize(R.dimen.floating_candidate_window_horizontal_margin);
}
private PopupWindow createPopupWindow(Context context) {
return new PopupWindow(new View(context) {
@Override
public boolean onTouchEvent(MotionEvent event) {
Optional<Rect> rect = layoutRenderer.getWindowRect();
if (!rect.isPresent()) {
return false;
}
MotionEvent copiedEvent = MotionEvent.obtain(event);
try {
copiedEvent.offsetLocation(rect.get().left, rect.get().top);
layoutRenderer.onTouchEvent(copiedEvent);
// TODO(hsumita): Don't invalidate the view if not necessary.
parentView.invalidate();
} finally {
copiedEvent.recycle();
}
return true;
}
});
}
@Override
public void draw(Canvas canvas) {
if (!isCandidateWindowShowing) {
return;
}
int saveId = canvas.save(Canvas.MATRIX_SAVE_FLAG);
try {
canvas.translate(offsetX, offsetY);
layoutRenderer.draw(canvas);
} finally {
canvas.restoreToCount(saveId);
}
}
@Override
public void viewSizeChanged(int width, int height) {
layoutRenderer.setMaxWidth(width - windowHorizontalMargin * 2);
updateCandidateWindowWithSize(width, height);
}
/** Sets {@link CursorAnchorInfo} to update the candidate window position. */
@Override
public void setCursorAnchorInfo(CursorAnchorInfo info) {
cursorAnchorInfo = Optional.of(info);
modeIndicator.setCursorAnchorInfo(info);
updateCandidateWindow();
}
/** Sets {@link Command} to update the contents of the candidate window. */
@Override
public void setCandidates(Command outCommand) {
Output output = Preconditions.checkNotNull(outCommand).getOutput();
layoutRenderer.setCandidates(outCommand);
modeIndicator.setCommand(outCommand);
highlightedCharacterStart = output.getPreedit().getHighlightedPosition();
int currentPreeditPosition = 0;
for (Segment segment : output.getPreedit().getSegmentList()) {
currentPreeditPosition += segment.getValueLength();
}
compositionCharacterEnd = currentPreeditPosition;
candidatesCategory = output.getCandidates().getCategory();
updateCandidateWindow();
}
/** Sets {@link EditorInfo} for context-aware behavior. */
@Override
public void setEditorInfo(EditorInfo editorInfo) {
Preconditions.checkNotNull(editorInfo);
boolean previusSuppressSuggestion = suppressSuggestion;
suppressSuggestion = shouldSuppressSuggestion(editorInfo);
if (previusSuppressSuggestion != suppressSuggestion) {
updateCandidateWindow();
}
}
@Override
public void setCompositionMode(CompositionMode mode) {
modeIndicator.setCompositionMode(mode);
}
/** Set view event listener to handle events invoked by the candidate window. */
@Override
public void setViewEventListener(ViewEventListener listener) {
layoutRenderer.setViewEventListener(Preconditions.checkNotNull(listener));
}
@Override
public void setVisibility(int visibility) {
if (visibility != View.VISIBLE) {
modeIndicator.hide();
}
}
/**
* Updates the candidate window.
* <p>
* All layout related states should be updated before call this method.
*/
private void updateCandidateWindow() {
updateCandidateWindowWithSize(parentView.getWidth(), parentView.getHeight());
}
private int calculateWindowLeftPosition(Rect rect, int basePositionX, int viewWidth) {
return MozcUtil.clamp(
basePositionX + rect.left,
windowHorizontalMargin, viewWidth - rect.width() - windowHorizontalMargin);
}
/**
* Updates the candidate window with width and height.
* <p>
* All layout related states should be updated before call this method.
*/
private void updateCandidateWindowWithSize(int viewWidth, int viewHeight) {
if (suppressSuggestion && candidatesCategory == Category.SUGGESTION) {
dismissCandidateWindow();
return;
}
Optional<Rect> optionalWindowRect = layoutRenderer.getWindowRect();
if (!optionalWindowRect.isPresent()) {
dismissCandidateWindow();
return;
}
Rect rect = optionalWindowRect.get();
updateBasePosition(rect, viewWidth);
int lowerAreaHeight = viewHeight - basePositionBottom - windowVerticalMargin;
int upperAreaHeight = basePositionTop - windowVerticalMargin;
int top = (lowerAreaHeight < rect.height() && lowerAreaHeight < upperAreaHeight)
? MozcUtil.clamp(basePositionTop - rect.height() - windowVerticalMargin,
0, viewHeight - rect.height())
: Math.max(0, basePositionBottom + windowVerticalMargin);
int left = calculateWindowLeftPosition(rect, basePositionX, viewWidth);
offsetX = left - rect.left;
offsetY = top - rect.top;
rect.offset(offsetX, offsetY);
showCandidateWindow(rect);
}
/** Return true if floating candidate window should be suppressed. */
private boolean shouldSuppressSuggestion(EditorInfo editorInfo) {
if ((editorInfo.inputType & EditorInfo.TYPE_MASK_CLASS) != InputType.TYPE_CLASS_TEXT) {
return true;
}
if ((editorInfo.inputType
& (InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE))
!= 0) {
return true;
}
switch (editorInfo.inputType & EditorInfo.TYPE_MASK_VARIATION) {
case InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS:
case InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS:
case InputType.TYPE_TEXT_VARIATION_PASSWORD:
case InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD:
case InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD:
case InputType.TYPE_TEXT_VARIATION_URI:
case InputType.TYPE_TEXT_VARIATION_FILTER:
return true;
default:
return false;
}
}
private void resetBasePosition() {
basePositionTop = 0;
basePositionBottom = 0;
basePositionX = 0;
return;
}
/**
* Update {@code basePositionTop}, {@code basePositionBottom} and {@code basePositionX} using
* {@code cursorAnchorInfo}.
*/
private void updateBasePosition(Rect windowRect, int viewWidth) {
if (!cursorAnchorInfo.isPresent()) {
resetBasePosition();
return;
}
CursorAnchorInfo info = cursorAnchorInfo.get();
int composingStartIndex = info.getComposingTextStart() + highlightedCharacterStart;
int composingEndIndex = info.getComposingTextStart() + compositionCharacterEnd - 1;
RectF firstCharacterBounds = info.getCharacterBounds(composingStartIndex);
float[] points;
if (firstCharacterBounds != null) {
points = new float[] {firstCharacterBounds.left, firstCharacterBounds.top,
firstCharacterBounds.left, firstCharacterBounds.bottom};
} else if (!Float.isNaN(info.getInsertionMarkerHorizontal())) {
points = new float[] {info.getInsertionMarkerHorizontal(), info.getInsertionMarkerTop(),
info.getInsertionMarkerHorizontal(), info.getInsertionMarkerBottom()};
} else {
resetBasePosition();
return;
}
// Adjust the bottom base position not to hide composition characters by the floating
// candidate window.
int windowLeft = calculateWindowLeftPosition(windowRect, (int) points[0], viewWidth);
for (int i = composingEndIndex; i > composingStartIndex; --i) {
RectF bounds = info.getCharacterBounds(i);
if (bounds == null) {
continue;
}
if (bounds.bottom <= points[3]) {
break;
}
if (bounds.right > windowLeft) {
points[3] = bounds.bottom;
break;
}
}
info.getMatrix().mapPoints(points);
int[] screenOffset = new int[2];
parentView.getLocationOnScreen(screenOffset);
basePositionX = Math.round(points[0]) - screenOffset[0];
basePositionTop = Math.round(points[1]) - screenOffset[1];
basePositionBottom = Math.round(points[3]) - screenOffset[1];
}
/**
* Shows the candidate window.
* <p>
* First {@code touchEventReceiverWindow} is shown (or is updated its position if it has been
* already shown). Then this view is invalidated. As the result {@code draw} will be called back
* and visible candidate window will be shown.
*/
private void showCandidateWindow(Rect rect) {
isCandidateWindowShowing = true;
if (touchEventReceiverWindow.isShowing()) {
touchEventReceiverWindow.update(rect.left, rect.top, rect.width(), rect.height());
} else {
touchEventReceiverWindow.setWidth(rect.width());
touchEventReceiverWindow.setHeight(rect.height());
touchEventReceiverWindow.showAtLocation(
parentView, Gravity.NO_GRAVITY, rect.left, rect.top);
}
parentView.postInvalidate();
}
/**
* Dismisses the candidate window.
* <p>
* Does the very similar things as {@showCandidateWindow}.
*/
private void dismissCandidateWindow() {
if (isCandidateWindowShowing) {
isCandidateWindowShowing = false;
touchEventReceiverWindow.dismiss();
parentView.postInvalidate();
}
}
@Override
public Optional<Rect> getVisibleRect() {
Optional<Rect> rect = layoutRenderer.getWindowRect();
if (touchEventReceiverWindow.isShowing() && rect.isPresent()) {
rect.get().offset(offsetX, offsetY);
return rect;
} else {
return Optional.<Rect>absent();
}
}
}
private final FloatingCandidateViewProxy floatingCandidateViewProxy;
public FloatingCandidateView(Context context) {
super(context);
floatingCandidateViewProxy = createFloatingCandidateViewInstance(this);
}
public FloatingCandidateView(Context context, AttributeSet attrs) {
super(context, attrs);
floatingCandidateViewProxy = createFloatingCandidateViewInstance(this);
}
@VisibleForTesting
FloatingCandidateView(Context context, PopupWindow popupWindowMock) {
super(context);
floatingCandidateViewProxy = new FloatingCandidateViewImpl(
this, popupWindowMock, new FloatingCandidateLayoutRenderer(context.getResources()),
new FloatingModeIndicator(this));
}
@VisibleForTesting
FloatingCandidateView(Context context, PopupWindow popupWindowMock,
FloatingCandidateLayoutRenderer layoutRenderer,
FloatingModeIndicator modeIndicator) {
super(context);
floatingCandidateViewProxy =
new FloatingCandidateViewImpl(this, popupWindowMock, layoutRenderer, modeIndicator);
}
private static FloatingCandidateViewProxy createFloatingCandidateViewInstance(View view) {
return isAvailable()
? new FloatingCandidateViewImpl(view)
: new FloatingCandidateViewStub();
}
@Override
protected void onFinishInflate() {
// Use software renderer since hardware renderer doesn't support Paint#setShadowLayer() and
// Canvas#drawPicture() which is used by MozcDrawable.
this.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
public static boolean isAvailable() {
return Build.VERSION.SDK_INT >= 21;
}
@Override
public void setVisibility(int visibility) {
super.setVisibility(visibility);
floatingCandidateViewProxy.setVisibility(visibility);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
floatingCandidateViewProxy.draw(canvas);
}
@Override
public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
super.onSizeChanged(width, height, oldWidth, oldHeight);
floatingCandidateViewProxy.viewSizeChanged(width, height);
}
/** Sets {@link CursorAnchorInfo} to update the candidate window position. */
public void setCursorAnchorInfo(CursorAnchorInfo info) {
floatingCandidateViewProxy.setCursorAnchorInfo(info);
}
/** Sets {@link Command} to update the contents of the candidate window. */
public void setCandidates(Command outCommand) {
floatingCandidateViewProxy.setCandidates(outCommand);
}
/** Sets {@link EditorInfo} for context-aware behavior. */
public void setEditorInfo(EditorInfo editorInfo) {
floatingCandidateViewProxy.setEditorInfo(editorInfo);
}
public void setCompositionMode(CompositionMode mode) {
floatingCandidateViewProxy.setCompositionMode(mode);
}
/** Set view event listener to handle events invoked by the candidate window. */
public void setViewEventListener(ViewEventListener listener) {
floatingCandidateViewProxy.setViewEventListener(listener);
}
@VisibleForTesting Optional<Rect> getVisibleRect() {
return floatingCandidateViewProxy.getVisibleRect();
}
}