| // 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(); |
| } |
| } |