blob: adae10932ca7e29437f5ccc2cdf54b589e50c034 [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.protobuf.ProtoCandidates.CandidateList;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.CandidateWord;
import org.mozc.android.inputmethod.japanese.ui.CandidateLayout.Row;
import org.mozc.android.inputmethod.japanese.ui.CandidateLayout.Span;
import org.mozc.android.inputmethod.japanese.view.CarrierEmojiRenderHelper;
import org.mozc.android.inputmethod.japanese.view.Skin;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import android.annotation.SuppressLint;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Align;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Layout;
import android.text.Layout.Alignment;
import android.text.StaticLayout;
import android.text.TextPaint;
import java.util.List;
import java.util.Locale;
/**
* Renders the {@link CandidateLayout} instance to the {@link Canvas}.
* After set all the parameter, clients can render the CandidateLayout as follows.
*
* {@code
* CandidateList candidateList = ...;
* CandidateLayoutRenderer renderer = ...;
* CandidateLayout candidateLayout = layouter.layout(candidateList);
* :
* :
* // it is necessary to set the original CandidateList before the actual rendering.
* renderer.setCandidateList(candidateList);
* :
* :
* renderer.drawCandidateLayout(canvas, candidateLayout, pressedCandidateIndex);
* :
* :
* // If the original candidateList is same, it's ok to invoke the rendering
* // twice or more, without re-invoking the setCandidateList.
* renderer.drawCandidateLayout(canvas, candidateLayout, pressedCandidateIndex);
* }
*/
public class CandidateLayoutRenderer {
/**
* The layout value width may be shorter than the rendered value without any settings.
* This policy sets how to compress (scale) the value.
*/
public enum ValueScalingPolicy {
/** Scales uniformly (in other words, the aspect ratio will be kept). */
UNIFORM,
/** Scales only horizontally, so the height of the text will be kept. */
HORIZONTAL,
}
/** Specifies if the description can keep its own region or not. */
public enum DescriptionLayoutPolicy {
/** The description's region will be shared with the value's. */
OVERLAY,
/**
* The description can keep its region exclusively (i.e., the value and description
* won't be overlapped).
*/
EXCLUSIVE,
/**
* Like View.GONE, the descriptor is not shown and doesn't occupy any are.
*/
GONE,
}
private static final int[] STATE_EMPTY = {};
// This is actually not the constant, but we should treat this as constant.
// Should not edit its contents.
private static final int[] STATE_FOCUSED = { android.R.attr.state_focused };
/** 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 final TextPaint valuePaint = createTextPaint(true, Color.BLACK, Align.LEFT);
private final TextPaint focusedValuePaint = createTextPaint(true, Color.BLACK, Align.LEFT);
private final TextPaint descriptionPaint = createTextPaint(true, Color.GRAY, Align.RIGHT);
private final Paint separatorPaint = new Paint();
/**
* The cache of Rect instance for the clip used in drawCandidateList method to reduce the
* number of resource allocation.
*/
private final Rect clipBounds = new Rect();
private float valueTextSize = 0;
private float valueHorizontalPadding = 0;
private float descriptionTextSize = 0;
private float descriptionHorizontalPadding = 0;
private float descriptionVerticalPadding = 0;
private ValueScalingPolicy valueScalingPolicy = ValueScalingPolicy.UNIFORM;
private DescriptionLayoutPolicy descriptionLayoutPolicy = DescriptionLayoutPolicy.OVERLAY;
private Optional<Drawable> spanBackgroundDrawable = Optional.absent();
@VisibleForTesting int focusedIndex = -1;
public CandidateLayoutRenderer() {
}
@SuppressLint("NewApi")
private static TextPaint createTextPaint(boolean antiAlias, int color, Align align) {
TextPaint textPaint = new TextPaint();
textPaint.setAntiAlias(antiAlias);
textPaint.setColor(color);
textPaint.setTextAlign(Preconditions.checkNotNull(align));
if (TEXT_LOCALE.isPresent()) {
textPaint.setTextLocale(TEXT_LOCALE.get());
}
return textPaint;
}
private static boolean isFocused(
CandidateWord candidateWord, int focusedIndex, int pressedCandidateIndex) {
int index = Preconditions.checkNotNull(candidateWord).getIndex();
return (index == focusedIndex) || (index == pressedCandidateIndex);
}
public void setSkin(Skin skin) {
Preconditions.checkNotNull(skin);
valuePaint.setColor(skin.candidateValueTextColor);
focusedValuePaint.setColor(skin.candidateValueFocusedTextColor);
descriptionPaint.setColor(skin.candidateDescriptionTextColor);
}
public void setValueTextSize(float valueTextSize) {
this.valueTextSize = valueTextSize;
this.valuePaint.setTextSize(valueTextSize);
}
public void setValueHorizontalPadding(float valueHorizontalPadding) {
this.valueHorizontalPadding = valueHorizontalPadding;
}
public void setDescriptionTextSize(float descriptionTextSize) {
this.descriptionTextSize = descriptionTextSize;
this.descriptionPaint.setTextSize(descriptionTextSize);
}
public void setDescriptionHorizontalPadding(float descriptionHorizontalPadding) {
this.descriptionHorizontalPadding = descriptionHorizontalPadding;
}
public void setDescriptionVerticalPadding(float descriptionVerticalPadding) {
this.descriptionVerticalPadding = descriptionVerticalPadding;
}
public void setValueScalingPolicy(ValueScalingPolicy valueScalingPolicy) {
this.valueScalingPolicy = Preconditions.checkNotNull(valueScalingPolicy);
}
public void setDescriptionLayoutPolicy(DescriptionLayoutPolicy descriptionLayoutPolicy) {
this.descriptionLayoutPolicy = Preconditions.checkNotNull(descriptionLayoutPolicy);
}
public void setSpanBackgroundDrawable(Optional<Drawable> drawable) {
this.spanBackgroundDrawable = Preconditions.checkNotNull(drawable);
}
public void setCandidateList(Optional<CandidateList> candidateList) {
Preconditions.checkNotNull(candidateList);
focusedIndex = (candidateList.isPresent() && candidateList.get().hasFocusedIndex())
? candidateList.get().getFocusedIndex()
: -1;
}
public void setSeparatorWidth(float separatorWidth) {
separatorPaint.setStrokeWidth(separatorWidth);
}
public void setSeparatorColor(int color) {
separatorPaint.setColor(color);
}
/**
* Renders the {@code candidateLayout} to the given {@code canvas}.
*/
public void drawCandidateLayout(
Canvas canvas, CandidateLayout candidateLayout, int pressedCandidateIndex,
CarrierEmojiRenderHelper carrierEmojiRenderHelper) {
Preconditions.checkNotNull(canvas);
Preconditions.checkNotNull(candidateLayout);
Rect clipBounds = this.clipBounds;
canvas.getClipBounds(clipBounds);
int focusedIndex = this.focusedIndex;
for (Row row : candidateLayout.getRowList()) {
float top = row.getTop();
if (top > clipBounds.bottom) {
break;
}
if (top + row.getHeight() < clipBounds.top) {
continue;
}
float separatorMargin = row.getHeight() * 0.2f;
float separatorTop = row.getTop() + separatorMargin;
float separatorBottom = row.getTop() + row.getHeight() - separatorMargin;
for (Span span : row.getSpanList()) {
if (span.getLeft() > clipBounds.right) {
break;
}
if (span.getRight() < clipBounds.left) {
continue;
}
// Even if span.getCandidateWord() is absent, draw the span in order to draw the background.
drawSpan(canvas, row, span,
span.getCandidateWord().isPresent()
&& isFocused(span.getCandidateWord().get(),
focusedIndex, pressedCandidateIndex),
carrierEmojiRenderHelper);
if (span.getLeft() != 0f) {
float separatorX = span.getLeft();
canvas.drawLine(separatorX, separatorTop, separatorX, separatorBottom, separatorPaint);
}
}
}
}
@VisibleForTesting void drawSpan(
Canvas canvas, Row row, Span span, boolean isFocused,
CarrierEmojiRenderHelper carrierEmojiRenderHelper) {
drawSpanBackground(
Preconditions.checkNotNull(canvas), Preconditions.checkNotNull(row), span, isFocused);
if (!span.getCandidateWord().isPresent()) {
return;
}
if (carrierEmojiRenderHelper.isRenderableEmoji(span.getCandidateWord().get().getValue())) {
drawCarrierEmoji(canvas, row, span, carrierEmojiRenderHelper);
} else {
drawText(canvas, row, span, isFocused);
}
drawDescription(canvas, row, span);
}
private void drawSpanBackground(Canvas canvas, Row row, Span span, boolean isFocused) {
if (!this.spanBackgroundDrawable.isPresent()) {
// No background available.
return;
}
Drawable spanBackgroundDrawable = this.spanBackgroundDrawable.get();
spanBackgroundDrawable.setBounds(
(int) span.getLeft(), (int) row.getTop(),
(int) span.getRight(), (int) (row.getTop() + row.getHeight()));
spanBackgroundDrawable.setState(isFocused ? STATE_FOCUSED : STATE_EMPTY);
spanBackgroundDrawable.draw(canvas);
}
private void drawCarrierEmoji(
Canvas canvas, Row row, Span span, CarrierEmojiRenderHelper carrierEmojiRenderHelper) {
Preconditions.checkState(span.getCandidateWord().isPresent());
float descriptionWidth = (descriptionLayoutPolicy == DescriptionLayoutPolicy.EXCLUSIVE)
? span.getDescriptionWidth() : 0;
float centerX = span.getLeft() + (span.getWidth() - descriptionWidth) / 2;
float centerY = row.getTop() + row.getHeight() / 2;
carrierEmojiRenderHelper.drawEmoji(canvas, span.getCandidateWord().get(), centerX, centerY);
}
private void drawText(Canvas canvas, Row row, Span span, boolean isFocused) {
Preconditions.checkState(span.getCandidateWord().isPresent());
String valueText = span.getCandidateWord().get().getValue();
if (valueText == null || valueText.length() == 0) {
// No value is available.
return;
}
// Calculate layout or get cached one.
// If isFocused is true, special paint should be applied.
// The resulting drawing is so special that it will not re reused.
// Therefore if isFocused is true cache is not used and always calculate the layout.
// In this case calculated layout is not cached.
Layout layout;
if (!isFocused && span.getCachedLayout().isPresent()) {
layout = span.getCachedLayout().get();
} else {
// Set the scaling of the text.
float descriptionWidth = (descriptionLayoutPolicy == DescriptionLayoutPolicy.EXCLUSIVE)
? span.getDescriptionWidth() : 0;
// Ensure that StaticLayout instance has positive width.
float displayValueWidth =
Math.max(1f, span.getWidth() - valueHorizontalPadding * 2 - descriptionWidth);
float textScale = Math.min(1f, displayValueWidth / span.getValueWidth());
TextPaint textPaint = isFocused ? this.focusedValuePaint : this.valuePaint;
if (valueScalingPolicy == ValueScalingPolicy.HORIZONTAL) {
textPaint.setTextSize(valueTextSize);
textPaint.setTextScaleX(textScale);
} else {
// Calculate the max limit of the "text size", in which we can render the candidate text
// inside the given span.
// Rendered text should be inside the givenWidth.
// Adjustment by font size can keep aspect ratio,
// which is important for Emoticon especially.
// Calculate the width with the default text size.
textPaint.setTextSize(valueTextSize * textScale);
}
// Layout's width is theoretically `span.getWidth() - descriptionWidth`.
// However because of the spec of Paint#setTextScaleX() and Paint#setTextSize(),
// Paint#measureText() might return larger width than what both above methods expect it to be.
// As a workaround, if theoretical width is smaller than the result of Paint#measureText(),
// employ the width returned by Paint#measureText().
// This workaround is to avoid from unexpected line-break.
// NOTE: Canvas#scale() cannot be used here because we have to use StaticLayout to draw
// Emoji and StaticLayout requires width in its constructor.
layout = new StaticLayout(
valueText, new TextPaint(textPaint),
(int) Math.ceil(Math.max(span.getWidth() - descriptionWidth,
textPaint.measureText(valueText))),
Alignment.ALIGN_CENTER, 1, 0, false);
if (!isFocused) {
span.setCachedLayout(layout);
}
}
// Actually render the image to the canvas.
int saveCount = canvas.save();
try {
canvas.translate(span.getLeft(), row.getTop() + (row.getHeight() - layout.getHeight()) / 2);
layout.draw(canvas);
} finally {
canvas.restoreToCount(saveCount);
}
}
private void drawDescription(Canvas canvas, Row row, Span span) {
List<String> descriptionList = span.getSplitDescriptionList();
if (span.getDescriptionWidth() <= 0 || descriptionList.isEmpty()
|| descriptionLayoutPolicy == DescriptionLayoutPolicy.GONE) {
// No description available or the layout policy is GONE.
return;
}
// Set the x-orientation scale based on the description's width to fit the span's region.
TextPaint descriptionPaint = this.descriptionPaint;
descriptionPaint.setTextSize(descriptionTextSize);
float centerOrRight;
if (descriptionLayoutPolicy == DescriptionLayoutPolicy.OVERLAY) {
float displayWidth = span.getWidth() - descriptionHorizontalPadding * 2;
descriptionPaint.setTextScaleX(Math.min(1f, displayWidth / span.getDescriptionWidth()));
descriptionPaint.setTextAlign(Align.CENTER);
centerOrRight = (span.getLeft() + span.getRight()) / 2f;
} else {
descriptionPaint.setTextScaleX(1f);
descriptionPaint.setTextAlign(Align.RIGHT);
centerOrRight = span.getRight() - descriptionHorizontalPadding;
}
// Render first "N" description lines based on the layout height.
float descriptionTextSize = this.descriptionTextSize;
float descriptionHeight = row.getHeight() - descriptionVerticalPadding * 2;
int numDescriptionLines =
Math.min((int) (descriptionHeight / descriptionTextSize), descriptionList.size());
float top = row.getTop() + row.getHeight()
- descriptionVerticalPadding - descriptionTextSize * (numDescriptionLines - 1);
for (String description : descriptionList.subList(0, numDescriptionLines)) {
canvas.drawText(description, centerOrRight, top, descriptionPaint);
top += descriptionTextSize;
}
}
}