blob: 89f75b507d963fd14f8ff65070bf97944a48e47a [file] [log] [blame]
// Copyright 2010-2015, 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.ui;
import org.mozc.android.inputmethod.japanese.MozcUtil;
import org.mozc.android.inputmethod.japanese.ViewEventListener;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.Candidates;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.Candidates.Candidate;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.Category;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.Command;
import org.mozc.android.inputmethod.japanese.resources.R;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.Align;
import android.graphics.Paint.FontMetrics;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.view.MotionEvent;
import java.util.Locale;
/**
* Layouts floating candidate window and draw it's contents on canvas.
*
* The point of origin of layout is NOT a left-top corner of candidate list BUT the left-top corner
* of the candidate column and the right-top corner of the shortcut column.
*
* TODO(hsumita): Rewrite using LinearLayout or something.
*/
public class FloatingCandidateLayoutRenderer {
private static class WindowRects {
public final Rect window;
public final Optional<Rect> focus;
public final Optional<Rect> pageIndicator;
public final Optional<RectF> scrollIndicator;
WindowRects(Rect window, Optional<Rect> focus, Optional<Rect> pageIndicator,
Optional<RectF> scrollIndicator) {
this.window = Preconditions.checkNotNull(window);
this.focus = Preconditions.checkNotNull(focus);
this.pageIndicator = Preconditions.checkNotNull(pageIndicator);
this.scrollIndicator = Preconditions.checkNotNull(scrollIndicator);
}
}
/** Locale field for {@link Paint#setTextLocale(Locale)}. */
private static final Optional<Locale> TEXT_LOCALE = (Build.VERSION.SDK_INT >= 17)
? Optional.of(Locale.JAPAN) : Optional.<Locale>absent();
private static final String FOOTER_TEXT_FORMAT = "%d / %d";
private final Paint candidatePaint;
private final Paint focusedCandidatePaint;
private final Paint descriptionPaint;
private final Paint shortcutPaint;
private final Paint footerPaint;
private final Paint separatorPaint;
private final Paint windowBackgroundPaint;
private final Paint focuseBackgroundPaint;
private final Paint scrollIndicatorPaint;
private final int windowMinimumWidth;
private final int windowHorizontalPadding;
private final float windowRoundRectRadius;
private final int candidateHeight;
private final int candidateOffsetY;
private final int candidateDescriptionMinimumPadding;
private final int footerHeight;
private final float footerTextCenterToBaseLineOffset;
private final int horizontalSeparatorPadding;
private final int shortcutWidth;
private final float shortcutCenterX;
private final int scrollIndicatorWidth;
private final int scrollIndicatorRadius;
private Optional<WindowRects> windowRects = Optional.absent();
private Optional<ViewEventListener> viewEventListener = Optional.absent();
private Optional<Candidates> candidates = Optional.absent();
private Optional<Integer> maxWidth = Optional.absent();
/** Focused candidate index, or tapped candidate index if exists. */
private Optional<Integer> focusedOrTappedCandidateIndexOnPage = Optional.absent();
/** TappedInfo for the current touch operation. Set on TOUCH_DOWN, reset on TOUCH_UP. */
private Optional<Integer> tappingCandidateIndex = Optional.absent();
private int totalCandidatesCount;
private int maxCandidateWidth;
private int maxDescriptionWidth;
public FloatingCandidateLayoutRenderer(Resources res) {
Preconditions.checkNotNull(res);
candidatePaint = new Paint();
candidatePaint.setColor(res.getColor(R.color.floating_candidate_text));
candidatePaint.setTextSize(res.getDimension(R.dimen.floating_candidate_text_size));
candidatePaint.setAntiAlias(true);
if (TEXT_LOCALE.isPresent()) {
candidatePaint.setTextLocale(TEXT_LOCALE.get());
}
focusedCandidatePaint = new Paint(candidatePaint);
focusedCandidatePaint.setColor(res.getColor(R.color.floating_candidate_focused_text));
descriptionPaint = new Paint(candidatePaint);
descriptionPaint.setTextSize(
res.getDimension(R.dimen.floating_candidate_description_text_size));
descriptionPaint.setColor(res.getColor(R.color.floating_candidate_description_text));
shortcutPaint = new Paint(candidatePaint);
shortcutPaint.setTextSize(res.getDimension(R.dimen.floating_candidate_shortcut_text_size));
shortcutPaint.setColor(res.getColor(R.color.floating_candidate_shortcut_text));
scrollIndicatorPaint = new Paint();
scrollIndicatorPaint.setColor(res.getColor(R.color.floating_candidate_scroll_indicator));
footerPaint = new Paint(candidatePaint);
footerPaint.setTextSize(res.getDimension(R.dimen.floating_candidate_footer_text_size));
footerPaint.setColor(res.getColor(R.color.floating_candidate_footer_text));
separatorPaint = new Paint();
separatorPaint.setStrokeWidth(
res.getDimension(R.dimen.floating_candidate_separator_width));
separatorPaint.setColor(res.getColor(R.color.floating_candidate_footer_separator));
windowBackgroundPaint = new Paint();
windowBackgroundPaint.setColor(res.getColor(R.color.floating_candidate_window_background));
windowBackgroundPaint.setShadowLayer(
res.getDimension(R.dimen.floating_candidate_window_shadow_radius),
0, res.getDimension(R.dimen.floating_candidate_window_shadow_offset_y),
res.getColor(R.color.floating_candidate_shadow));
focuseBackgroundPaint = new Paint();
focuseBackgroundPaint.setColor(res.getColor(R.color.floating_candidate_focus_background));
float candidateVerticalPadding =
res.getDimension(R.dimen.floating_candidate_candidate_vertical_padding);
FontMetrics candidateMetrics = candidatePaint.getFontMetrics();
candidateHeight = (int) Math.ceil(
candidateMetrics.descent - candidateMetrics.ascent + candidateVerticalPadding * 2);
candidateOffsetY = (int) Math.ceil(-candidateMetrics.ascent + candidateVerticalPadding);
windowMinimumWidth = res.getDimensionPixelSize(R.dimen.floating_candidate_window_minimum_width);
windowHorizontalPadding =
res.getDimensionPixelOffset(R.dimen.floating_candidate_window_horizontal_padding);
windowRoundRectRadius = res.getDimension(R.dimen.floating_candidate_window_round_rect_radius);
candidateDescriptionMinimumPadding =
res.getDimensionPixelSize(R.dimen.floating_candidate_candidate_description_minimum_padding);
horizontalSeparatorPadding =
res.getDimensionPixelSize(R.dimen.floating_candidate_separator_horizontal_padding);
scrollIndicatorWidth =
res.getDimensionPixelSize(R.dimen.floating_candidate_scroll_indicator_width);
scrollIndicatorRadius =
res.getDimensionPixelSize(R.dimen.floating_candidate_scroll_indicator_radius);
FontMetrics footerMetrics = footerPaint.getFontMetrics();
float footerTextHeight = -footerMetrics.ascent + footerMetrics.descent;
footerHeight = Math.round(footerTextHeight * 2f);
footerTextCenterToBaseLineOffset = (-footerMetrics.ascent - footerMetrics.descent) / 2f;
float shortcutCharacterWidth = shortcutPaint.measureText("m");
float shortcutCandidatePadding =
res.getDimensionPixelSize(R.dimen.floating_candidate_shortcut_candidate_padding);
shortcutWidth = Math.round(shortcutCharacterWidth + shortcutCandidatePadding);
shortcutCenterX = -shortcutCharacterWidth / 2f - shortcutCandidatePadding;
updateLayout();
}
/** Handle touch event and invoke some actions. */
public void onTouchEvent(MotionEvent event) {
if (!candidates.isPresent() || !viewEventListener.isPresent()) {
return;
}
ViewEventListener listener = viewEventListener.get();
Optional<Integer> optionalCandidateIndex = getTappingCandidate(event);
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
tappingCandidateIndex = optionalCandidateIndex;
updateLayout();
return;
}
if (event.getActionMasked() != MotionEvent.ACTION_UP) {
return;
}
if (!optionalCandidateIndex.isPresent() || !tappingCandidateIndex.isPresent()
|| !optionalCandidateIndex.equals(tappingCandidateIndex)) {
tappingCandidateIndex = Optional.absent();
updateLayout();
return;
}
int candidateIndex = optionalCandidateIndex.get();
tappingCandidateIndex = Optional.absent();
listener.onConversionCandidateSelected(
candidates.get().getCandidate(candidateIndex).getId(),
Optional.<Integer>absent());
}
/** Sets the max width of this window. */
public void setMaxWidth(int maxWidth) {
if (maxWidth > 0) {
this.maxWidth = Optional.of(maxWidth);
} else {
this.maxWidth = Optional.absent();
}
updateLayout();
}
/** Sets candidates. */
public void setCandidates(Command outCommand) {
Preconditions.checkNotNull(outCommand);
if (outCommand.getOutput().getCandidates().getCandidateCount() == 0) {
candidates = Optional.<Candidates>absent();
totalCandidatesCount = 0;
} else {
candidates = Optional.of(outCommand.getOutput().getCandidates());
totalCandidatesCount = outCommand.getOutput().getAllCandidateWords().getCandidatesCount();
}
updateLayout();
}
/** Sets a view event listener to handle touch events. */
public void setViewEventListener(ViewEventListener listener) {
viewEventListener = Optional.of(listener);
}
/**
* Gets the rectangle of this window.
* Defensive-copied value is returned so caller-side can modify it.
*/
public Optional<Rect> getWindowRect() {
if (windowRects.isPresent()) {
return Optional.of(new Rect(windowRects.get().window));
} else {
return Optional.absent();
}
}
/** Draws this candidate window. */
public void draw(Canvas canvas) {
Preconditions.checkNotNull(canvas);
Preconditions.checkState(candidates.isPresent());
Preconditions.checkState(windowRects.isPresent());
Candidates candidatesData = candidates.get();
WindowRects rects = windowRects.get();
canvas.drawRoundRect(
new RectF(rects.window), windowRoundRectRadius, windowRoundRectRadius,
windowBackgroundPaint);
if (rects.focus.isPresent()) {
canvas.drawRect(rects.focus.get(), focuseBackgroundPaint);
}
// Candidates, descriptions and shortcuts.
int focusedIndex = focusedOrTappedCandidateIndexOnPage.or(-1);
for (int i = 0; i < candidatesData.getCandidateCount(); ++i) {
Candidate candidate = candidatesData.getCandidate(i);
int offsetY = getCandidateRowOffsetY(i) + candidateOffsetY;
Paint paint = (i == focusedIndex) ? focusedCandidatePaint : candidatePaint;
drawTextWithLimit(canvas, candidate.getValue(), paint, 0, offsetY, maxCandidateWidth);
if (candidate.getAnnotation().hasDescription()) {
drawTextWithAlignAndLimit(
canvas, candidate.getAnnotation().getDescription(), descriptionPaint,
rects.window.right - windowHorizontalPadding, offsetY,
Align.RIGHT, maxDescriptionWidth);
}
if (candidate.getAnnotation().hasShortcut()) {
drawTextWithAlign(
canvas, candidate.getAnnotation().getShortcut(), shortcutPaint,
shortcutCenterX, offsetY, Align.CENTER);
}
}
// Footer. Don't show if suggestion mode.
if (rects.pageIndicator.isPresent()) {
Rect indicatorRect = rects.pageIndicator.get();
drawHorizontalSeparator(
canvas, separatorPaint, rects.window.left, rects.window.right, indicatorRect.top);
drawPageIndicator(canvas, footerPaint, indicatorRect);
}
// Scroll indicator
if (rects.scrollIndicator.isPresent()) {
canvas.drawRoundRect(
rects.scrollIndicator.get(), scrollIndicatorRadius, scrollIndicatorRadius,
scrollIndicatorPaint);
}
}
private void drawPageIndicator(Canvas canvas, Paint paint, Rect rect) {
drawTextWithAlign(
canvas, String.format(FOOTER_TEXT_FORMAT,
candidates.get().getFocusedIndex() + 1, totalCandidatesCount),
paint, rect.exactCenterX(), rect.exactCenterY() + footerTextCenterToBaseLineOffset,
Align.CENTER);
}
private void drawHorizontalSeparator(Canvas canvas, Paint paint, int startX, int endX, int y) {
canvas.drawLine(
Math.min(startX, endX) + horizontalSeparatorPadding, y,
Math.max(startX, endX) - horizontalSeparatorPadding, y, paint);
}
/**
* Draws {@code text} into {@code canvas} with the text align and the limitation of text width.
* <p>
* If measured width of {@code text} is wider than maxWidth, the {@code text} is drawn with
* horizontal compression in order to fit {@code maxWidth}.
*/
private void drawTextWithAlignAndLimit(
Canvas canvas, String text, Paint paint, float x, float y, Align align, float maxWidth) {
float textWidth = paint.measureText(text);
int saveCount = canvas.save(Canvas.MATRIX_SAVE_FLAG);
Align originalAlign = paint.getTextAlign();
try {
canvas.translate(x, y);
if (textWidth > maxWidth) {
// Use Canvas#scale() instead of Paint#setTextScaleX() for accurate scaling.
canvas.scale(maxWidth / textWidth, 1.0f);
}
paint.setTextAlign(align);
canvas.drawText(text, 0, 0, paint);
} finally {
canvas.restoreToCount(saveCount);
paint.setTextAlign(originalAlign);
}
}
/** See {@link #drawTextWithAlignAndLimit}. */
private void drawTextWithAlign(
Canvas canvas, String text, Paint paint, float x, float y, Align align) {
drawTextWithAlignAndLimit(canvas, text, paint, x, y, align, Float.MAX_VALUE);
}
/** See {@link #drawTextWithAlignAndLimit}. */
private void drawTextWithLimit(
Canvas canvas, String text, Paint paint, float x, float y, float maxWidth) {
drawTextWithAlignAndLimit(canvas, text, paint, x, y, paint.getTextAlign(), maxWidth);
}
private Optional<Integer> getTappingCandidate(MotionEvent event) {
if (!windowRects.isPresent()) {
return Optional.absent();
}
WindowRects rects = windowRects.get();
int x = Math.round(event.getX());
int y = Math.round(event.getY());
if (!rects.window.contains(x, y)) {
return Optional.absent();
}
int candidateIndex = y / candidateHeight;
if (candidateIndex < candidates.get().getCandidateCount()) {
return Optional.of(candidateIndex);
} else {
return Optional.absent();
}
}
private void updateLayout() {
if (!candidates.isPresent() || !maxWidth.isPresent()) {
windowRects = Optional.absent();
return;
}
Candidates candidatesData = candidates.get();
int candidateNumberOnPage = candidatesData.getCandidateCount();
boolean hasShortcut = candidatesData.getCandidateCount() > 0
&& !candidatesData.getCandidate(0).getAnnotation().getShortcut().isEmpty();
int leftEdgePosition = hasShortcut
? -windowHorizontalPadding - shortcutWidth : -windowHorizontalPadding;
// Candidates and descriptions
maxCandidateWidth = 0;
maxDescriptionWidth = 0;
for (int i = 0; i < candidateNumberOnPage; ++i) {
Candidate candidate = candidatesData.getCandidate(i);
maxCandidateWidth = Math.max(
maxCandidateWidth, Math.round(candidatePaint.measureText(candidate.getValue())));
maxDescriptionWidth = Math.max(
maxDescriptionWidth,
Math.round(descriptionPaint.measureText(candidate.getAnnotation().getDescription())));
}
int fixedWidth =
-leftEdgePosition + candidateDescriptionMinimumPadding + windowHorizontalPadding;
int flexibleWidth = maxCandidateWidth + maxDescriptionWidth;
if (fixedWidth + flexibleWidth > maxWidth.get()) {
int availableWidth = maxWidth.get() - fixedWidth;
float shrinkRate = MozcUtil.clamp((float) availableWidth / flexibleWidth, 0f, 1f);
maxDescriptionWidth = Math.round(maxDescriptionWidth * shrinkRate);
maxCandidateWidth = availableWidth - maxDescriptionWidth;
}
int rightEdgePosition = Math.max(
Math.min(windowMinimumWidth, maxWidth.get()) + leftEdgePosition,
maxCandidateWidth + candidateDescriptionMinimumPadding + maxDescriptionWidth
+ windowHorizontalPadding);
// Footer
int horizontalSeparatorY = candidateHeight * candidateNumberOnPage;
int bottomEdgePosition;
Optional<Rect> pageIndicatorRect;
if (candidatesData.getCategory() != Category.SUGGESTION) {
bottomEdgePosition = horizontalSeparatorY + footerHeight;
pageIndicatorRect = Optional.of(
new Rect(leftEdgePosition, horizontalSeparatorY, rightEdgePosition, bottomEdgePosition));
} else {
bottomEdgePosition = horizontalSeparatorY;
pageIndicatorRect = Optional.absent();
}
// Focus
Optional<Rect> focusRect = Optional.absent();
focusedOrTappedCandidateIndexOnPage = getTappedOrFocusedIndexOnPage();
if (focusedOrTappedCandidateIndexOnPage.isPresent()) {
int offsetY = candidateHeight * focusedOrTappedCandidateIndexOnPage.get();
focusRect = Optional.of(new Rect(
leftEdgePosition, offsetY, rightEdgePosition, offsetY + candidateHeight));
} else {
focusRect = Optional.absent();
}
// Scroll indicator
Optional<RectF> scrollIndicatorRect;
if (totalCandidatesCount > candidatesData.getPageSize()) {
int currentPageIndex = getCurrentPageNumber() - 1;
float scrollIndicatorHeight =
(float) bottomEdgePosition * candidatesData.getPageSize() / totalCandidatesCount;
float scrollIndicatorOffset = scrollIndicatorHeight * currentPageIndex;
scrollIndicatorRect = Optional.of(new RectF(
rightEdgePosition - scrollIndicatorWidth, scrollIndicatorOffset, rightEdgePosition,
Math.min(bottomEdgePosition, scrollIndicatorOffset + scrollIndicatorHeight)));
} else {
scrollIndicatorRect = Optional.absent();
}
// Window
Rect windowRect = new Rect(leftEdgePosition, 0, rightEdgePosition, bottomEdgePosition);
windowRects = Optional.of(
new WindowRects(windowRect, focusRect, pageIndicatorRect, scrollIndicatorRect));
}
private Optional<Integer> getTappedOrFocusedIndexOnPage() {
if (tappingCandidateIndex.isPresent()) {
return tappingCandidateIndex;
} else if (candidates.isPresent() && candidates.get().hasFocusedIndex()) {
return Optional.of(candidates.get().getFocusedIndex() % candidates.get().getPageSize());
}
return Optional.absent();
}
private int getCandidateRowOffsetY(int index) {
return index * candidateHeight;
}
private int getCurrentPageNumber() {
return (int) Math.ceil(
(float) (candidates.get().getFocusedIndex() + 1) / candidates.get().getPageSize());
}
}