Merge "Helper methods for TextBoundsInfo"

This commit is contained in:
Haoyu Zhang
2022-12-13 22:37:13 +00:00
committed by Android (Google) Code Review
2 changed files with 551 additions and 0 deletions

View File

@@ -54551,6 +54551,8 @@ package android.view.inputmethod {
method @NonNull public android.text.SegmentFinder getGraphemeSegmentFinder();
method @NonNull public android.text.SegmentFinder getLineSegmentFinder();
method @NonNull public android.graphics.Matrix getMatrix();
method public int getOffsetForPosition(float, float);
method @Nullable public int[] getRangeForRect(@NonNull android.graphics.RectF, @NonNull android.text.SegmentFinder, @NonNull android.text.Layout.TextInclusionStrategy);
method public int getStart();
method @NonNull public android.text.SegmentFinder getWordSegmentFinder();
method public void writeToParcel(@NonNull android.os.Parcel, int);

View File

@@ -25,6 +25,7 @@ import android.graphics.RectF;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.Layout;
import android.text.SegmentFinder;
import com.android.internal.util.ArrayUtils;
@@ -313,6 +314,554 @@ public final class TextBoundsInfo implements Parcelable {
return mLineSegmentFinder;
}
/**
* Return the index of the closest character to the given position.
* It's similar to the text layout API {@link Layout#getOffsetForHorizontal(int, float)}.
* And it's mainly used to find the cursor index (the index of the character before which the
* cursor should be placed) for the given position. It's guaranteed that the returned index is
* a grapheme break. Check {@link #getGraphemeSegmentFinder()} for more information.
*
* <p>It's assumed that the editor lays out text in horizontal lines from top to bottom and each
* line is laid out according to the display algorithm specified in
* <a href="https://unicode.org/reports/tr9/#Basic_Display_Algorithm"> unicode bidirectional
* algorithm</a>.
* </p>
*
* <p> This method won't check the text ranges whose line information is missing. For example,
* the {@link TextBoundsInfo}'s range is from index 5 to 15. If the associated
* {@link SegmentFinder} only identifies one line range from 7 to 12. Then this method
* won't check the text in the ranges of [5, 7) and [12, 15).
* </p>
*
* @param x the x coordinates of the interested location, in the editor's coordinates.
* @param y the y coordinates of the interested location, in the editor's coordinates.
* @return the index of the character whose position is closest to the given location. It will
* return -1 if it can't find a character.
*
* @see Layout#getOffsetForHorizontal(int, float)
*/
public int getOffsetForPosition(float x, float y) {
final int[] lineRange = new int[2];
final RectF lineBounds = new RectF();
getLineInfo(y, lineRange, lineBounds);
// No line is found, return -1;
if (lineRange[0] == -1 || lineRange[1] == -1) return -1;
final int lineStart = lineRange[0];
final int lineEnd = lineRange[1];
final boolean lineEndsWithLinefeed =
(getCharacterFlags(lineEnd - 1) & FLAG_CHARACTER_LINEFEED) != 0;
// Consider the following 2 cases:
// Case 1:
// Text: "AB\nCD"
// Layout: AB
// CD
// Case 2:
// Text: "ABCD"
// Layout: AB
// CD
// If user wants to insert a 'X' character at the end of the first line:
// In case 1, 'X' is inserted before the last character '\n'.
// In case 2, 'X' is inserted after the last character 'B'.
// So if a line ends with linefeed, it shouldn't check the cursor position after the last
// character.
final int lineLimit;
if (lineEndsWithLinefeed) {
lineLimit = lineEnd;
} else {
lineLimit = lineEnd + 1;
}
// Point graphemeStart to the start of the first grapheme segment intersects with the line.
int graphemeStart = mGraphemeSegmentFinder.nextEndBoundary(lineStart);
// The grapheme information is missing.
if (graphemeStart == SegmentFinder.DONE) return -1;
graphemeStart = mGraphemeSegmentFinder.previousStartBoundary(graphemeStart);
int target = -1;
float minDistance = Float.MAX_VALUE;
while (graphemeStart != SegmentFinder.DONE && graphemeStart < lineLimit) {
if (graphemeStart >= lineStart) {
float cursorPosition = getCursorHorizontalPosition(graphemeStart, lineStart,
lineEnd, lineBounds.left, lineBounds.right);
final float distance = Math.abs(cursorPosition - x);
if (distance < minDistance) {
minDistance = distance;
target = graphemeStart;
}
}
graphemeStart = mGraphemeSegmentFinder.nextStartBoundary(graphemeStart);
}
return target;
}
/**
* Whether the primary position at the given index is the previous character's trailing
* position. <br/>
*
* For LTR character, trailing position is its right edge. For RTL character, trailing position
* is its left edge.
*
* The primary position is defined as the position of a newly inserted character with the
* context direction at the given offset. In contrast, the secondary position is the position
* of a newly inserted character with the context's opposite direction at the given offset.
*
* In Android, the trailing position is used for primary position when the direction run after
* the given index has a higher level than the current direction run.
*
* <p>
* For example:
* (L represents LTR character, and R represents RTL character. The number is the index)
* <pre>
* input text: L0 L1 L2 R3 R4 R5 L6 L7 L8
* render result: L0 L1 L2 R5 R4 R3 L6 L7 L8
* BiDi Run: [ Run 0 ][ Run 1 ][ Run 2 ]
* BiDi Level: 0 0 0 1 1 1 0 0 0
* </pre>
*
* The index 3 is a BiDi transition point, the cursor can be placed either after L2 or before
* R3. Because the bidi level of run 1 is higher than the run 0, this method returns true. And
* the cursor should be placed after L2.
* <pre>
* render result: L0 L1 L2 R5 R4 R3 L6 L7 L8
* position after L2: |
* position before R3: |
* result position: |
* </pre>
*
* The index 6 is also a Bidi transition point, the 2 possible cursor positions are exactly the
* same as index 3. However, since the bidi level of run 2 is higher than the run 1, this
* method returns false. And the cursor should be placed before L6.
* <pre>
* render result: L0 L1 L2 R5 R4 R3 L6 L7 L8
* position after R5: |
* position before L6: |
* result position: |
* </pre>
*
* This method helps guarantee that the cursor index and the cursor position forms a one to
* one relation.
* </p>
*
* @param offset the offset of the character in front of which the cursor is placed. It must be
* the start index of a grapheme. And it must be in the range from lineStart to
* lineEnd. An offset equal to lineEnd is allowed. It indicates that the cursor is
* placed at the end of current line instead of the start of the following line.
* @param lineStart the start index of the line that index belongs to, inclusive.
* @param lineEnd the end index of the line that index belongs to, exclusive.
* @return true if primary position is the trailing position of the previous character.
*
* @see #getCursorHorizontalPosition(int, int, int, float, float)
*/
private boolean primaryIsTrailingPrevious(int offset, int lineStart, int lineEnd) {
final int bidiLevel;
if (offset < lineEnd) {
bidiLevel = getCharacterBidiLevel(offset);
} else {
// index equals to lineEnd, use line's BiDi level for the BiDi run.
boolean lineIsRtl =
(getCharacterFlags(offset - 1) & FLAG_LINE_IS_RTL) == FLAG_LINE_IS_RTL;
bidiLevel = lineIsRtl ? 1 : 0;
}
final int bidiLevelBefore;
if (offset > lineStart) {
// Here it assumes index is always the start of a grapheme. And (index - 1) belongs to
// the previous grapheme.
bidiLevelBefore = getCharacterBidiLevel(offset - 1);
} else {
// index equals to lineStart, use line's BiDi level for previous BiDi run.
boolean lineIsRtl =
(getCharacterFlags(offset) & FLAG_LINE_IS_RTL) == FLAG_LINE_IS_RTL;
bidiLevelBefore = lineIsRtl ? 1 : 0;
}
return bidiLevelBefore < bidiLevel;
}
/**
* Returns the x coordinates of the cursor at the given index. (The index of the character
* before which the cursor should be placed.)
*
* @param index the character index before which the cursor is placed. It must be the start
* index of a grapheme. It must be in the range from lineStart to lineEnd.
* An index equal to lineEnd is allowed. It indicates that the cursor is
* placed at the end of current line instead of the start of the following line.
* @param lineStart start index of the line that index belongs to, inclusive.
* @param lineEnd end index of the line that index belongs, exclusive.
* @return the x coordinates of the cursor at the given index,
*
* @see #primaryIsTrailingPrevious(int, int, int)
*/
private float getCursorHorizontalPosition(int index, int lineStart, int lineEnd,
float lineLeft, float lineRight) {
Preconditions.checkArgumentInRange(index, lineStart, lineEnd, "index");
final boolean lineIsRtl = (getCharacterFlags(lineStart) & FLAG_LINE_IS_RTL) != 0;
final boolean isPrimaryIsTrailingPrevious =
primaryIsTrailingPrevious(index, lineStart, lineEnd);
// The index of the character used to compute the cursor position.
final int targetIndex;
// Whether to use the start position of the character.
// For LTR character start is the left edge. For RTL character, start is the right edge.
final boolean isStart;
if (isPrimaryIsTrailingPrevious) {
// (index - 1) belongs to the previous line(if any), return the line start position.
if (index <= lineStart) {
return lineIsRtl ? lineRight : lineLeft;
}
targetIndex = index - 1;
isStart = false;
} else {
// index belongs to the next line(if any), return the line end position.
if (index >= lineEnd) {
return lineIsRtl ? lineLeft : lineRight;
}
targetIndex = index;
isStart = true;
}
// The BiDi level is odd when the character is RTL.
final boolean isRtl = (getCharacterBidiLevel(targetIndex) & 1) != 0;
final int offset = targetIndex - mStart;
// If the character is RTL, the start is the right edge. Otherwise, the start is the
// left edge:
// +-----------------------+
// | | start | end |
// |-------+-------+-------|
// | RTL | right | left |
// |-------+-------+-------|
// | LTR | left | right |
// +-------+-------+-------+
return (isRtl != isStart) ? mCharacterBounds[4 * offset] : mCharacterBounds[4 * offset + 2];
}
/**
* Return the minimal rectangle that contains all the characters in the given range.
*
* @param start the start index of the given range, inclusive.
* @param end the end index of the given range, exclusive.
* @param rectF the {@link RectF} to receive the bounds.
*/
private void getBoundsForRange(int start, int end, @NonNull RectF rectF) {
Preconditions.checkArgumentInRange(start, mStart, mEnd - 1, "start");
Preconditions.checkArgumentInRange(end, start, mEnd, "end");
if (end <= start) {
rectF.setEmpty();
return;
}
rectF.left = Float.MAX_VALUE;
rectF.top = Float.MAX_VALUE;
rectF.right = Float.MIN_VALUE;
rectF.bottom = Float.MIN_VALUE;
for (int index = start; index < end; ++index) {
final int offset = index - mStart;
rectF.left = Math.min(rectF.left, mCharacterBounds[4 * offset]);
rectF.top = Math.min(rectF.top, mCharacterBounds[4 * offset + 1]);
rectF.right = Math.max(rectF.right, mCharacterBounds[4 * offset + 2]);
rectF.bottom = Math.max(rectF.bottom, mCharacterBounds[4 * offset + 3]);
}
}
/**
* Return the character range and bounds of the closest line to the given {@code y} coordinate,
* in the editor's local coordinates.
*
* If the given y is above the first line or below the last line -1 will be returned for line
* start and end.
*
* This method assumes that the lines are laid out from the top to bottom.
*
* @param y the y coordinates used to search for the line.
* @param characterRange a two element array used to receive the character range of the line.
* If no valid line is found -1 will be returned for both start and end.
* @param bounds {@link RectF} to receive the line bounds result, nullable. If given, it can
* still be modified even if no valid line is found.
*/
private void getLineInfo(float y, @NonNull int[] characterRange, @Nullable RectF bounds) {
characterRange[0] = -1;
characterRange[1] = -1;
// Starting from the first line.
int currentLineEnd = mLineSegmentFinder.nextEndBoundary(mStart);
if (currentLineEnd == SegmentFinder.DONE) return;
int currentLineStart = mLineSegmentFinder.previousStartBoundary(currentLineEnd);
float top = Float.MAX_VALUE;
float bottom = Float.MIN_VALUE;
float minDistance = Float.MAX_VALUE;
final RectF currentLineBounds = new RectF();
while (currentLineStart != SegmentFinder.DONE && currentLineStart < mEnd) {
final int lineStartInRange = Math.max(mStart, currentLineStart);
final int lineEndInRange = Math.min(mEnd, currentLineEnd);
getBoundsForRange(lineStartInRange, lineEndInRange, currentLineBounds);
top = Math.min(currentLineBounds.top, top);
bottom = Math.max(currentLineBounds.bottom, bottom);
final float distance = verticalDistance(currentLineBounds, y);
if (distance == 0f) {
characterRange[0] = currentLineStart;
characterRange[1] = currentLineEnd;
if (bounds != null) {
bounds.set(currentLineBounds);
}
return;
}
if (distance < minDistance) {
minDistance = distance;
characterRange[0] = currentLineStart;
characterRange[1] = currentLineEnd;
if (bounds != null) {
bounds.set(currentLineBounds);
}
}
if (y < bounds.top) break;
currentLineStart = mLineSegmentFinder.nextStartBoundary(currentLineStart);
currentLineEnd = mLineSegmentFinder.nextEndBoundary(currentLineEnd);
}
// y is above the first line or below the last line. The founded line is still invalid,
// clear the result.
if (y < top || y > bottom) {
characterRange[0] = -1;
characterRange[1] = -1;
if (bounds != null) {
bounds.setEmpty();
}
}
}
/**
* Finds the range of text which is inside the specified rectangle area. This method is a
* counterpart of the
* {@link Layout#getRangeForRect(RectF, SegmentFinder, Layout.TextInclusionStrategy)}.
*
* <p>It's assumed that the editor lays out text in horizontal lines from top to bottom
* and each line is laid out according to the display algorithm specified in
* <a href="https://unicode.org/reports/tr9/#Basic_Display_Algorithm"> unicode bidirectional
* algorithm</a>.
* </p>
*
* <p> This method won't check the text ranges whose line information is missing. For example,
* the {@link TextBoundsInfo}'s range is from index 5 to 15. If the associated line
* {@link SegmentFinder} only identifies one line range from 7 to 12. Then this method
* won't check the text in the ranges of [5, 7) and [12, 15).
* </p>
*
* @param area area for which the text range will be found
* @param segmentFinder SegmentFinder for determining the ranges of text to be considered as a
* text segment
* @param inclusionStrategy strategy for determining whether a text segment is inside the
* specified area
* @return the text range stored in a two element int array. The first element is the
* start (inclusive) of the text range, and the second element is the end (exclusive) character
* offsets of the text range, or null if there are no text segments inside the area.
*
* @see Layout#getRangeForRect(RectF, SegmentFinder, Layout.TextInclusionStrategy)
*/
@Nullable
public int[] getRangeForRect(@NonNull RectF area, @NonNull SegmentFinder segmentFinder,
@NonNull Layout.TextInclusionStrategy inclusionStrategy) {
int lineEnd = mLineSegmentFinder.nextEndBoundary(mStart);
// Line information is missing.
if (lineEnd == SegmentFinder.DONE) return null;
int lineStart = mLineSegmentFinder.previousStartBoundary(lineEnd);
int start = -1;
while (lineStart != SegmentFinder.DONE && start == -1) {
start = getStartForRectWithinLine(lineStart, lineEnd, area, segmentFinder,
inclusionStrategy);
lineStart = mLineSegmentFinder.nextStartBoundary(lineStart);
lineEnd = mLineSegmentFinder.nextEndBoundary(lineEnd);
}
// Can't find the start index; the specified contains no valid segment.
if (start == -1) return null;
lineStart = mLineSegmentFinder.previousStartBoundary(mEnd);
// Line information is missing.
if (lineStart == SegmentFinder.DONE) return null;
lineEnd = mLineSegmentFinder.nextEndBoundary(lineStart);
int end = -1;
while (lineEnd > start && end == -1) {
end = getEndForRectWithinLine(lineStart, lineEnd, area, segmentFinder,
inclusionStrategy);
lineStart = mLineSegmentFinder.previousStartBoundary(lineStart);
lineEnd = mLineSegmentFinder.previousEndBoundary(lineEnd);
}
// We've already found start, end is guaranteed to be found at this point.
start = segmentFinder.previousStartBoundary(start + 1);
end = segmentFinder.nextEndBoundary(end - 1);
return new int[] { start, end };
}
/**
* Find the start character index of the first text segments within a line inside the specified
* {@code area}.
*
* @param lineStart the start of this line, inclusive .
* @param lineEnd the end of this line, exclusive.
* @param area the area inside which the text segments will be found.
* @param segmentFinder SegmentFinder for determining the ranges of text to be considered a
* text segment.
* @param inclusionStrategy strategy for determining whether a text segment is inside the
* specified area.
* @return the start index of the first segment in the area.
*/
private int getStartForRectWithinLine(int lineStart, int lineEnd, @NonNull RectF area,
@NonNull SegmentFinder segmentFinder,
@NonNull Layout.TextInclusionStrategy inclusionStrategy) {
if (lineStart >= lineEnd) return -1;
int runStart = lineStart;
int runLevel = -1;
// Check the BiDi runs and search for the start index.
for (int index = lineStart; index < lineEnd; ++index) {
final int level = getCharacterBidiLevel(index);
if (level != runLevel) {
final int start = getStartForRectWithinRun(runStart, index, area, segmentFinder,
inclusionStrategy);
if (start != -1) {
return start;
}
runStart = index;
runLevel = level;
}
}
return getStartForRectWithinRun(runStart, lineEnd, area, segmentFinder, inclusionStrategy);
}
/**
* Find the start character index of the first text segments within the directional run inside
* the specified {@code area}.
*
* @param runStart the start of this directional run, inclusive.
* @param runEnd the end of this directional run, exclusive.
* @param area the area inside which the text segments will be found.
* @param segmentFinder SegmentFinder for determining the ranges of text to be considered a
* text segment.
* @param inclusionStrategy strategy for determining whether a text segment is inside the
* specified area.
* @return the start index of the first segment in the area.
*/
private int getStartForRectWithinRun(int runStart, int runEnd, @NonNull RectF area,
@NonNull SegmentFinder segmentFinder,
@NonNull Layout.TextInclusionStrategy inclusionStrategy) {
if (runStart >= runEnd) return -1;
int segmentEndOffset = segmentFinder.nextEndBoundary(runStart);
// No segment is found in run.
if (segmentEndOffset == SegmentFinder.DONE) return -1;
int segmentStartOffset = segmentFinder.previousStartBoundary(segmentEndOffset);
final RectF segmentBounds = new RectF();
while (segmentStartOffset != SegmentFinder.DONE && segmentStartOffset < runEnd) {
final int start = Math.max(runStart, segmentStartOffset);
final int end = Math.min(runEnd, segmentEndOffset);
getBoundsForRange(start, end, segmentBounds);
// Find the first segment inside the area, return the start.
if (inclusionStrategy.isSegmentInside(segmentBounds, area)) return start;
segmentStartOffset = segmentFinder.nextStartBoundary(segmentStartOffset);
segmentEndOffset = segmentFinder.nextEndBoundary(segmentEndOffset);
}
return -1;
}
/**
* Find the end character index of the last text segments within a line inside the specified
* {@code area}.
*
* @param lineStart the start of this line, inclusive .
* @param lineEnd the end of this line, exclusive.
* @param area the area inside which the text segments will be found.
* @param segmentFinder SegmentFinder for determining the ranges of text to be considered a
* text segment.
* @param inclusionStrategy strategy for determining whether a text segment is inside the
* specified area.
* @return the end index of the last segment in the area.
*/
private int getEndForRectWithinLine(int lineStart, int lineEnd, @NonNull RectF area,
@NonNull SegmentFinder segmentFinder,
@NonNull Layout.TextInclusionStrategy inclusionStrategy) {
if (lineStart >= lineEnd) return -1;
lineStart = Math.max(lineStart, mStart);
lineEnd = Math.min(lineEnd, mEnd);
// The exclusive run end index.
int runEnd = lineEnd;
int runLevel = -1;
// Check the BiDi runs backwards and search for the end index.
for (int index = lineEnd - 1; index >= lineStart; --index) {
final int level = getCharacterBidiLevel(index);
if (level != runLevel) {
final int end = getEndForRectWithinRun(index + 1, runEnd, area, segmentFinder,
inclusionStrategy);
if (end != -1) return end;
runEnd = index + 1;
runLevel = level;
}
}
return getEndForRectWithinRun(lineStart, runEnd, area, segmentFinder, inclusionStrategy);
}
/**
* Find the end character index of the last text segments within the directional run inside the
* specified {@code area}.
*
* @param runStart the start of this directional run, inclusive.
* @param runEnd the end of this directional run, exclusive.
* @param area the area inside which the text segments will be found.
* @param segmentFinder SegmentFinder for determining the ranges of text to be considered a
* text segment.
* @param inclusionStrategy strategy for determining whether a text segment is inside the
* specified area.
* @return the end index of the last segment in the area.
*/
private int getEndForRectWithinRun(int runStart, int runEnd, @NonNull RectF area,
@NonNull SegmentFinder segmentFinder,
@NonNull Layout.TextInclusionStrategy inclusionStrategy) {
if (runStart >= runEnd) return -1;
int segmentStart = segmentFinder.previousStartBoundary(runEnd);
// No segment is found before the runEnd.
if (segmentStart == SegmentFinder.DONE) return -1;
int segmentEnd = segmentFinder.nextEndBoundary(segmentStart);
final RectF segmentBounds = new RectF();
while (segmentEnd != SegmentFinder.DONE && segmentEnd > runStart) {
final int start = Math.max(runStart, segmentStart);
final int end = Math.min(runEnd, segmentEnd);
getBoundsForRange(start, end, segmentBounds);
// Find the last segment inside the area, return the end.
if (inclusionStrategy.isSegmentInside(segmentBounds, area)) return end;
segmentStart = segmentFinder.previousStartBoundary(segmentStart);
segmentEnd = segmentFinder.previousEndBoundary(segmentEnd);
}
return -1;
}
/**
* Get the vertical distance from the {@code pointF} to the {@code rectF}. It's useful to find
* the corresponding line for a given point.
*/
private static float verticalDistance(@NonNull RectF rectF, float y) {
if (rectF.top <= y && y < rectF.bottom) {
return 0f;
}
if (y < rectF.top) {
return rectF.top - y;
}
return y - rectF.bottom;
}
/**
* Describe the kinds of special objects contained in this Parcelable
* instance's marshaled representation. For example, if the object will