blob: 6580820550b617bd942c4380c2deed1225b68a9e [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.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 com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Layouts the conversion candidate words.
*
* First, all the rows this layouter creates are split into "chunk"s.
* The width of each chunk is equal to {@code pageWidth / numChunks} evenly.
* Next, the candidates are assigned to chunks. The order of the candidates is kept.
* A candidate may occupy one or more successive chunks which are on the same row.
*
* The height of each row is round up to integer, so that the snap-paging
* should work well.
*
*/
public class ConversionCandidateLayouter implements CandidateLayouter {
/**
* The metrics between chunk and span.
*
* The main purpose of this class is to inject the chunk compression
* heuristics for testing.
*/
static class ChunkMetrics {
private final float chunkWidth;
private final float compressionRatio;
private final float horizontalPadding;
private final float minWidth;
ChunkMetrics(float chunkWidth,
float compressionRatio,
float horizontalPadding,
float minWidth) {
this.chunkWidth = chunkWidth;
this.compressionRatio = compressionRatio;
this.horizontalPadding = horizontalPadding;
this.minWidth = minWidth;
}
/** Returns the number of chunks which the span would consume. */
int getNumChunks(Span span) {
Preconditions.checkNotNull(span);
float compressedValueWidth =
compressValueWidth(span.getValueWidth(), compressionRatio, horizontalPadding, minWidth);
return (int) Math.ceil((compressedValueWidth + span.getDescriptionWidth()) / chunkWidth);
}
static float compressValueWidth(
float valueWidth, float compressionRatio, float horizontalPadding, float minWidth) {
// Sum of geometric progression.
// a == 1.0 (default pixel size)
// r == candidateWidthCompressionRate (pixel width decay rate)
// n == defaultWidth
if (compressionRatio != 1) {
valueWidth =
(1f - (float) Math.pow(compressionRatio, valueWidth)) / (1f - compressionRatio);
}
return Math.max(valueWidth + horizontalPadding * 2, minWidth);
}
}
private Optional<SpanFactory> spanFactory = Optional.absent();
/** Horizontal common ratio of the value size. */
private float valueWidthCompressionRate;
/** Minimum width of the value. */
private float minValueWidth;
/** The Minimum width of the chunk. */
private float minChunkWidth;
/** Height of the value. */
private float valueHeight;
private float valueHorizontalPadding;
private float valueVerticalPadding;
/** The current view's width. */
private int viewWidth;
private boolean reserveEmptySpan = false;
/**
* @param spanFactory the spanFactory to set
*/
public void setSpanFactory(SpanFactory spanFactory) {
this.spanFactory = Optional.of(Preconditions.checkNotNull(spanFactory));
}
/**
* @param valueWidthCompressionRate the valueWidthCompressionRate to set
*/
public void setValueWidthCompressionRate(float valueWidthCompressionRate) {
this.valueWidthCompressionRate = valueWidthCompressionRate;
}
/**
* @param minValueWidth the minValueWidth to set
*/
public void setMinValueWidth(float minValueWidth) {
this.minValueWidth = minValueWidth;
}
/**
* @param minChunkWidth the minChunkWidth to set
*/
public void setMinChunkWidth(float minChunkWidth) {
this.minChunkWidth = minChunkWidth;
}
/**
* @param valueHeight the valueHeight to set
*/
public void setValueHeight(float valueHeight) {
this.valueHeight = valueHeight;
}
/**
* @param valueHorizontalPadding the valueHorizontalPadding to set
*/
public void setValueHorizontalPadding(float valueHorizontalPadding) {
this.valueHorizontalPadding = valueHorizontalPadding;
}
/**
* @param valueVerticalPadding the valueVerticalPadding to set
*/
public void setValueVerticalPadding(float valueVerticalPadding) {
this.valueVerticalPadding = valueVerticalPadding;
}
@Override
public boolean setViewSize(int width, int height) {
if (viewWidth == width) {
// Doesn't need to invalidate the layout if the width isn't changed.
return false;
}
viewWidth = width;
return true;
}
private int getNumChunks() {
return (int) (viewWidth / minChunkWidth);
}
public float getChunkWidth() {
return viewWidth / (float) getNumChunks();
}
@Override
public int getPageWidth() {
return Math.max(viewWidth, 0);
}
public int getRowHeight() {
return (int) Math.ceil(valueHeight + valueVerticalPadding * 2);
}
@Override
public int getPageHeight() {
return getRowHeight();
}
@Override
public Optional<CandidateLayout> layout(CandidateList candidateList) {
Preconditions.checkNotNull(candidateList);
if (minChunkWidth <= 0 || viewWidth <= 0 || candidateList.getCandidatesCount() == 0 ||
!spanFactory.isPresent()) {
return Optional.<CandidateLayout>absent();
}
int numChunks = getNumChunks();
float chunkWidth = getChunkWidth();
ChunkMetrics chunkMetrics = new ChunkMetrics(
chunkWidth, valueWidthCompressionRate, valueHorizontalPadding, minValueWidth);
List<Row> rowList = buildRowList(candidateList, spanFactory.get(), numChunks, chunkMetrics,
reserveEmptySpan);
int[] numAllocatedChunks = new int[numChunks];
boolean isFirst = reserveEmptySpan;
for (Row row : rowList) {
layoutSpanList(
row.getSpanList(),
(isFirst ? (viewWidth - (int) chunkWidth) : viewWidth),
(isFirst ? numChunks - 1 : numChunks),
chunkMetrics, numAllocatedChunks);
isFirst = false;
}
// Push empty span at the end of the first row.
if (reserveEmptySpan) {
Span emptySpan = new Span(Optional.<CandidateWord>absent(), 0, 0,
Collections.<String>emptyList());
List<Span> spanList = rowList.get(0).getSpanList();
emptySpan.setLeft(spanList.get(spanList.size() - 1).getRight());
emptySpan.setRight(viewWidth);
rowList.get(0).addSpan(emptySpan);
}
// In order to snap the scrolling on any row boundary, rounding up the rowHeight
// to align pixels.
int rowHeight = getRowHeight();
layoutRowList(rowList, viewWidth, rowHeight);
return Optional.of(new CandidateLayout(rowList, viewWidth, rowHeight * rowList.size()));
}
/**
* Builds the row list based on the number of estimated chunks for each span.
*
* The order of the candidates will be kept.
*/
@VisibleForTesting
static List<Row> buildRowList(
CandidateList candidateList, SpanFactory spanFactory,
int numChunks, ChunkMetrics chunkMetrics, boolean enableSpan) {
Preconditions.checkNotNull(candidateList);
Preconditions.checkNotNull(spanFactory);
Preconditions.checkNotNull(chunkMetrics);
List<Row> rowList = new ArrayList<Row>();
int numRemainingChunks = 0;
for (CandidateWord candidateWord : candidateList.getCandidatesList()) {
Span span = spanFactory.newInstance(candidateWord);
int numSpanChunks = chunkMetrics.getNumChunks(span);
if (numRemainingChunks < numSpanChunks) {
// There is no space on the current row to put the current span.
// Create a new row.
numRemainingChunks = numChunks;
// For the first line, we reserve a chunk at right-top in order to place an icon
// button for folding/expanding keyboard.
if (enableSpan && rowList.isEmpty()) {
numRemainingChunks--;
}
rowList.add(new Row());
}
// Add the span to the last row.
rowList.get(rowList.size() - 1).addSpan(span);
numRemainingChunks -= numSpanChunks;
}
return rowList;
}
/**
* Sets left and right of each span. The left and right should be aligned to the chunks.
* Also, the right of the last span should be equal to {@code pageWidth}.
*
* In order to avoid integer array memory allocation (as this method will be invoked
* many times to layout a {@link CandidateList}), it is necessary to pass an integer
* array for the calculation buffer, {@code numAllocatedChunks}.
* The size of the buffer must be equal to or greater than {@code spanList.size()}.
* Its elements needn't be initialized.
*/
@VisibleForTesting
static void layoutSpanList(
List<Span> spanList, int pageWidth,
int numChunks, ChunkMetrics chunkMetrics, int[] numAllocatedChunks) {
Preconditions.checkNotNull(spanList);
Preconditions.checkNotNull(chunkMetrics);
Preconditions.checkNotNull(numAllocatedChunks);
Preconditions.checkArgument(spanList.size() <= numAllocatedChunks.length);
int numRemainingChunks = numChunks;
// First, allocate the chunks based on the metrics.
{
int index = 0;
for (Span span : spanList) {
int numSpanChunks = Math.min(numRemainingChunks, chunkMetrics.getNumChunks(span));
numAllocatedChunks[index] = numSpanChunks;
numRemainingChunks -= numSpanChunks;
++index;
}
}
// Then assign remaining chunks to each span as even as possible by round-robin.
for (int index = 0; numRemainingChunks > 0;
--numRemainingChunks, index = (index + 1) % spanList.size()) {
++numAllocatedChunks[index];
}
// Set the actual left and right to each span.
{
int index = 0;
float left = 0;
float spanWidth = pageWidth / (float) numChunks;
int cumulativeNumAllocatedChunks = 0;
for (Span span : spanList) {
cumulativeNumAllocatedChunks += numAllocatedChunks[index++];
float right = Math.min(spanWidth * cumulativeNumAllocatedChunks, pageWidth);
span.setLeft(left);
span.setRight(right);
left = right;
}
}
// Set the right of the last element to the pageWidth to align the page.
spanList.get(spanList.size() - 1).setRight(pageWidth);
}
/** Sets top, width and height to the each row. */
@VisibleForTesting
static void layoutRowList(List<Row> rowList, int pageWidth, int rowHeight) {
int top = 0;
for (Row row : Preconditions.checkNotNull(rowList)) {
row.setTop(top);
row.setWidth(pageWidth);
row.setHeight(rowHeight);
top += rowHeight;
}
}
public void reserveEmptySpanForInputFoldButton(boolean reserveEmptySpan) {
this.reserveEmptySpan = reserveEmptySpan;
}
}