diff --git a/core/java/android/text/Layout.java b/core/java/android/text/Layout.java index 0f910cc20d120..7ee38a6f1d1e8 100644 --- a/core/java/android/text/Layout.java +++ b/core/java/android/text/Layout.java @@ -2047,9 +2047,11 @@ public abstract class Layout { final String ellipsisString = TextUtils.getEllipsisString(method); final int ellipsisStringLen = ellipsisString.length(); + // Use the ellipsis string only if there are that at least as many characters to replace. + final boolean useEllipsisString = ellipsisCount >= ellipsisStringLen; for (int i = 0; i < ellipsisCount; i++) { final char c; - if (i < ellipsisStringLen && ellipsisCount <= ellipsisStringLen) { + if (useEllipsisString && i < ellipsisStringLen) { c = ellipsisString.charAt(i); } else { c = TextUtils.ELLIPSIS_FILLER; diff --git a/core/java/android/text/StaticLayout.java b/core/java/android/text/StaticLayout.java index c5bd29e2a4331..61f1d1de9a268 100644 --- a/core/java/android/text/StaticLayout.java +++ b/core/java/android/text/StaticLayout.java @@ -707,7 +707,7 @@ public class StaticLayout extends Layout { // TODO: Support more justification mode, e.g. letter spacing, stretching. b.mJustificationMode != Layout.JUSTIFICATION_MODE_NONE); if (mLeftIndents != null || mRightIndents != null) { - // TODO(raph) performance: it would be better to do this once per layout rather + // TODO(performance): it would be better to do this once per layout rather // than once per paragraph, but that would require a change to the native // interface. int leftLen = mLeftIndents == null ? 0 : mLeftIndents.length; @@ -794,7 +794,7 @@ public class StaticLayout extends Layout { width += widths[j]; } } - flag |= flags[i] & TAB_MASK; + flag |= flags[i] & TAB_MASK; // XXX May need to also have starting hyphen edit } // Treat the last line and overflowed lines as a single line. breaks[remainingLineCount - 1] = breaks[breakCount - 1]; @@ -947,6 +947,25 @@ public class StaticLayout extends Layout { bottom = fm.bottom; } + // Information about hyphenation, tabs, and directions are needed for determining + // ellipsization, so the values should be assigned before ellipsization. + + // TODO: could move TAB to share same column as HYPHEN, simplifying this code and gaining + // one bit for start field + lines[off + TAB] = flags & TAB_MASK; + lines[off + HYPHEN] = flags; + + lines[off + DIR] |= dir << DIR_SHIFT; + // easy means all chars < the first RTL, so no emoji, no nothing + // XXX a run with no text or all spaces is easy but might be an empty + // RTL paragraph. Make sure easy is false if this is the case. + if (easy) { + mLineDirections[j] = DIRS_ALL_LEFT_TO_RIGHT; + } else { + mLineDirections[j] = AndroidBidi.directions(dir, chdirs, start - widthStart, chs, + start - widthStart, end - start); + } + boolean firstLine = (j == 0); boolean currentLineIsTheLastVisibleOne = (j + 1 == mMaximumVisibleLineCount); @@ -962,8 +981,8 @@ public class StaticLayout extends Layout { ellipsize == TextUtils.TruncateAt.END); if (doEllipsis) { calculateEllipsis(start, end, widths, widthStart, - ellipsisWidth, ellipsize, j, - textWidth, paint, forceEllipsis); + ellipsisWidth - getTotalInsets(j), ellipsize, j, + textWidth, paint, forceEllipsis, dir); } } @@ -1002,7 +1021,7 @@ public class StaticLayout extends Layout { extra = 0; } - lines[off + START] = start; + lines[off + START] |= start; lines[off + TOP] = v; lines[off + DESCENT] = below + extra; @@ -1019,33 +1038,13 @@ public class StaticLayout extends Layout { lines[off + mColumns + START] = end; lines[off + mColumns + TOP] = v; - // TODO: could move TAB to share same column as HYPHEN, simplifying this code and gaining - // one bit for start field - lines[off + TAB] |= flags & TAB_MASK; - lines[off + HYPHEN] = flags; - - lines[off + DIR] |= dir << DIR_SHIFT; - Directions linedirs = DIRS_ALL_LEFT_TO_RIGHT; - // easy means all chars < the first RTL, so no emoji, no nothing - // XXX a run with no text or all spaces is easy but might be an empty - // RTL paragraph. Make sure easy is false if this is the case. - if (easy) { - mLineDirections[j] = linedirs; - } else { - mLineDirections[j] = AndroidBidi.directions(dir, chdirs, start - widthStart, chs, - start - widthStart, end - start); - } - mLineCount++; return v; } - private void calculateEllipsis(int lineStart, int lineEnd, - float[] widths, int widthStart, - float avail, TextUtils.TruncateAt where, - int line, float textWidth, TextPaint paint, - boolean forceEllipsis) { - avail -= getTotalInsets(line); + private void calculateEllipsis(int lineStart, int lineEnd, float[] widths, int widthStart, + float avail, TextUtils.TruncateAt where, int line, float textWidth, TextPaint paint, + boolean forceEllipsis, int dir) { if (textWidth <= avail && !forceEllipsis) { // Everything fits! mLines[mColumns * line + ELLIPSIS_START] = 0; @@ -1053,11 +1052,35 @@ public class StaticLayout extends Layout { return; } - float ellipsisWidth = paint.measureText(TextUtils.getEllipsisString(where)); - int ellipsisStart = 0; - int ellipsisCount = 0; - int len = lineEnd - lineStart; + float tempAvail = avail; + while (true) { + float ellipsizedWidth = guessEllipsis(lineStart, lineEnd, widths, widthStart, tempAvail, + where, line, textWidth, paint, forceEllipsis, dir); + if (ellipsizedWidth <= avail) { + mEllipsized = true; + return; // The ellipsized line fits. + } else { + // Some side effect of ellipsization has caused the text to go over the available + // width. Let's make the available width shorter by exactly that amount and retry. + tempAvail -= ellipsizedWidth - avail; + } + } + } + // Returns the width of the ellipsized line which in some rare cases can actually be larger + // than 'avail' (due to kerning or other context-based effect of replacement of text by + // ellipsis). If all the line needs to ellipsized away, or it's an invalud hyphenation mode, + // returns 0 so the caller can stop iterating. + private float guessEllipsis(int lineStart, int lineEnd, float[] widths, int widthStart, + float avail, TextUtils.TruncateAt where, int line, float textWidth, TextPaint paint, + boolean forceEllipsis, int dir) { + final float ellipsisWidth = paint.measureText(TextUtils.getEllipsisString(where)); + final int ellipsisStart; + final int ellipsisCount; + final int len = lineEnd - lineStart; + final int offset = lineStart - widthStart; + + int hyphen = getHyphen(line); // We only support start ellipsis on a single line if (where == TextUtils.TruncateAt.START) { if (mMaximumVisibleLineCount == 1) { @@ -1065,9 +1088,9 @@ public class StaticLayout extends Layout { int i; for (i = len; i > 0; i--) { - float w = widths[i - 1 + lineStart - widthStart]; + final float w = widths[i - 1 + offset]; if (w + sum + ellipsisWidth > avail) { - while (i < len && widths[i + lineStart - widthStart] == 0.0f) { + while (i < len && widths[i + offset] == 0.0f) { i++; } break; @@ -1078,9 +1101,13 @@ public class StaticLayout extends Layout { ellipsisStart = 0; ellipsisCount = i; + // Strip the potential hyphenation at beginning of line. + hyphen &= ~Paint.HYPHENEDIT_MASK_START_OF_LINE; } else { + ellipsisStart = 0; + ellipsisCount = 0; if (Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, "Start Ellipsis only supported with one line"); + Log.w(TAG, "Start ellipsis only supported with one line"); } } } else if (where == TextUtils.TruncateAt.END || where == TextUtils.TruncateAt.MARQUEE || @@ -1089,7 +1116,7 @@ public class StaticLayout extends Layout { int i; for (i = 0; i < len; i++) { - float w = widths[i + lineStart - widthStart]; + final float w = widths[i + offset]; if (w + sum + ellipsisWidth > avail) { break; @@ -1098,24 +1125,27 @@ public class StaticLayout extends Layout { sum += w; } - ellipsisStart = i; - ellipsisCount = len - i; - if (forceEllipsis && ellipsisCount == 0 && len > 0) { + if (forceEllipsis && i == len && len > 0) { ellipsisStart = len - 1; ellipsisCount = 1; + } else { + ellipsisStart = i; + ellipsisCount = len - i; } - } else { - // where = TextUtils.TruncateAt.MIDDLE We only support middle ellipsis on a single line + // Strip the potential hyphenation at end of line. + hyphen &= ~Paint.HYPHENEDIT_MASK_END_OF_LINE; + } else { // where = TextUtils.TruncateAt.MIDDLE + // We only support middle ellipsis on a single line. if (mMaximumVisibleLineCount == 1) { float lsum = 0, rsum = 0; int left = 0, right = len; - float ravail = (avail - ellipsisWidth) / 2; + final float ravail = (avail - ellipsisWidth) / 2; for (right = len; right > 0; right--) { - float w = widths[right - 1 + lineStart - widthStart]; + final float w = widths[right - 1 + offset]; if (w + rsum > ravail) { - while (right < len && widths[right + lineStart - widthStart] == 0.0f) { + while (right < len && widths[right + offset] == 0.0f) { right++; } break; @@ -1123,9 +1153,9 @@ public class StaticLayout extends Layout { rsum += w; } - float lavail = avail - ellipsisWidth - rsum; + final float lavail = avail - ellipsisWidth - rsum; for (left = 0; left < right; left++) { - float w = widths[left + lineStart - widthStart]; + final float w = widths[left + offset]; if (w + lsum > lavail) { break; @@ -1137,14 +1167,46 @@ public class StaticLayout extends Layout { ellipsisStart = left; ellipsisCount = right - left; } else { + ellipsisStart = 0; + ellipsisCount = 0; if (Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, "Middle Ellipsis only supported with one line"); + Log.w(TAG, "Middle ellipsis only supported with one line"); } } } - mEllipsized = true; mLines[mColumns * line + ELLIPSIS_START] = ellipsisStart; mLines[mColumns * line + ELLIPSIS_COUNT] = ellipsisCount; + + if (ellipsisStart == 0 && (ellipsisCount == 0 || ellipsisCount == len)) { + // Unsupported ellipsization mode or all text is ellipsized away. Return 0. + return 0.0f; + } + + final CharSequence text = getText(); + final boolean hasTabs = getLineContainsTab(line); + final TabStops tabStops; + if (hasTabs && text instanceof Spanned) { + final TabStopSpan[] tabs = getParagraphSpans((Spanned) text, lineStart, lineEnd, + TabStopSpan.class); + if (tabs.length == 0) { + tabStops = null; + } else { + tabStops = new TabStops(TAB_INCREMENT, tabs); + } + } else { + tabStops = null; + } + final TextLine textline = TextLine.obtain(); + paint.setHyphenEdit(hyphen); + textline.set(paint, + text, /* this is an instance of Ellipsizer or SpannedEllipsizer */ + lineStart, lineEnd, + dir, getLineDirections(line), + hasTabs, tabStops); + final float hyphenatedWidth = textline.metrics(null); + paint.setHyphenEdit(0); + TextLine.recycle(textline); + return hyphenatedWidth; } private float getTotalInsets(int line) { diff --git a/core/java/android/text/TextUtils.java b/core/java/android/text/TextUtils.java index ba5eaec9f9f2d..14ddd8b6deae2 100644 --- a/core/java/android/text/TextUtils.java +++ b/core/java/android/text/TextUtils.java @@ -88,8 +88,8 @@ public class TextUtils { /** {@hide} */ @NonNull - public static String getEllipsisString(@NonNull TextUtils.TruncateAt method) { - return (method == TextUtils.TruncateAt.END_SMALL) ? ELLIPSIS_TWO_DOTS : ELLIPSIS_NORMAL; + public static String getEllipsisString(@NonNull TruncateAt method) { + return (method == TruncateAt.END_SMALL) ? ELLIPSIS_TWO_DOTS : ELLIPSIS_NORMAL; } @@ -1177,9 +1177,11 @@ public class TextUtils { * or, if it does not fit, a truncated * copy with ellipsis character added at the specified edge or center. */ - public static CharSequence ellipsize(CharSequence text, - TextPaint p, - float avail, TruncateAt where) { + @NonNull + public static CharSequence ellipsize(@NonNull CharSequence text, + @NonNull TextPaint p, + @FloatRange(from = 0.0) float avail, + @NonNull TruncateAt where) { return ellipsize(text, p, avail, where, false, null); } @@ -1195,9 +1197,11 @@ public class TextUtils { * report the start and end of the ellipsized range. TextDirection * is determined by the first strong directional character. */ - public static CharSequence ellipsize(CharSequence text, - TextPaint paint, - float avail, TruncateAt where, + @NonNull + public static CharSequence ellipsize(@NonNull CharSequence text, + @NonNull TextPaint paint, + @FloatRange(from = 0.0) float avail, + @NonNull TruncateAt where, boolean preserveLength, @Nullable EllipsizeCallback callback) { return ellipsize(text, paint, avail, where, preserveLength, callback, @@ -1218,16 +1222,19 @@ public class TextUtils { * * @hide */ - public static CharSequence ellipsize(CharSequence text, - TextPaint paint, - float avail, TruncateAt where, + @NonNull + public static CharSequence ellipsize(@NonNull CharSequence text, + @NonNull TextPaint paint, + @FloatRange(from = 0.0) float avail, + @NonNull TruncateAt where, boolean preserveLength, @Nullable EllipsizeCallback callback, - TextDirectionHeuristic textDir, String ellipsis) { + @NonNull TextDirectionHeuristic textDir, + @NonNull String ellipsis) { - int len = text.length(); - - MeasuredText mt = MeasuredText.obtain(); + final int len = text.length(); + final MeasuredText mt = MeasuredText.obtain(); + MeasuredText resultMt = null; try { float width = setPara(mt, paint, text, 0, text.length(), textDir); @@ -1235,74 +1242,88 @@ public class TextUtils { if (callback != null) { callback.ellipsized(0, 0); } - return text; } - // XXX assumes ellipsis string does not require shaping and - // is unaffected by style - float ellipsiswid = paint.measureText(ellipsis); - avail -= ellipsiswid; - - int left = 0; - int right = len; - if (avail < 0) { - // it all goes - } else if (where == TruncateAt.START) { - right = len - mt.breakText(len, false, avail); - } else if (where == TruncateAt.END || where == TruncateAt.END_SMALL) { - left = mt.breakText(len, true, avail); - } else { - right = len - mt.breakText(len, false, avail / 2); - avail -= mt.measure(right, len); - left = mt.breakText(right, true, avail); - } - - if (callback != null) { - callback.ellipsized(left, right); - } - - char[] buf = mt.mChars; - Spanned sp = text instanceof Spanned ? (Spanned) text : null; - - final int removed = right - left; - final int remaining = len - removed; - if (preserveLength) { - if (remaining > 0 && removed >= ellipsis.length()) { - ellipsis.getChars(0, ellipsis.length(), buf, left); - left += ellipsis.length(); - } // else skip the ellipsis - for (int i = left; i < right; i++) { - buf[i] = ELLIPSIS_FILLER; + resultMt = MeasuredText.obtain(); + // First estimate of effective width of ellipsis. + float ellipsisWidth = paint.measureText(ellipsis); + while (true) { // Repeat until the ellipsized text fits. + final float remainingWidth = avail - ellipsisWidth; + final int start, end; + if (remainingWidth < 0) { + // Even the ellipsis can't fit. So it all goes. + start = 0; + end = len; + } else if (where == TruncateAt.START) { + start = 0; + end = len - mt.breakText(len, false /* backwards */, remainingWidth); + } else if (where == TruncateAt.END || where == TruncateAt.END_SMALL) { + start = mt.breakText(len, true /* forwards */, remainingWidth); + end = len; + } else { + end = len - mt.breakText(len, false /* backwards */, remainingWidth / 2); + start = mt.breakText(end, true /* forwards */, + remainingWidth - mt.measure(end, len)); } - String s = new String(buf, 0, len); - if (sp == null) { - return s; + + final char[] buf = mt.mChars; + final Spanned sp = text instanceof Spanned ? (Spanned) text : null; + + final CharSequence result; + final int removed = end - start; + final int remaining = len - removed; + if (preserveLength) { + int pos = start; + if (remaining > 0 && removed >= ellipsis.length()) { + ellipsis.getChars(0, ellipsis.length(), buf, start); + pos += ellipsis.length(); + } // else eliminate the ellipsis + while (pos < end) { + buf[pos++] = ELLIPSIS_FILLER; + } + final String s = new String(buf, 0, len); + if (sp == null) { + result = s; + } else { + final SpannableString ss = new SpannableString(s); + copySpansFrom(sp, 0, len, Object.class, ss, 0); + result = ss; + } + } else { + if (remaining == 0) { + result = ""; + } else if (sp == null) { + final StringBuilder sb = new StringBuilder(remaining + ellipsis.length()); + sb.append(buf, 0, start); + sb.append(ellipsis); + sb.append(buf, end, len - end); + result = sb.toString(); + } else { + final SpannableStringBuilder ssb = new SpannableStringBuilder(); + ssb.append(text, 0, start); + ssb.append(ellipsis); + ssb.append(text, end, len); + result = ssb; + } } - SpannableString ss = new SpannableString(s); - copySpansFrom(sp, 0, len, Object.class, ss, 0); - return ss; - } - if (remaining == 0) { - return ""; + width = setPara(resultMt, paint, result, 0, result.length(), textDir); + if (width <= avail || remaining == 0) { // Text fits or all text is gone. + if (callback != null) { + callback.ellipsized(start, end); + } + return result; + } else { + // Adjust the width of the ellipsis by adding the amount 'width' is still over. + ellipsisWidth += width - avail; + } } - - if (sp == null) { - StringBuilder sb = new StringBuilder(remaining + ellipsis.length()); - sb.append(buf, 0, left); - sb.append(ellipsis); - sb.append(buf, right, len - right); - return sb.toString(); - } - - SpannableStringBuilder ssb = new SpannableStringBuilder(); - ssb.append(text, 0, left); - ssb.append(ellipsis); - ssb.append(text, right, len); - return ssb; } finally { MeasuredText.recycle(mt); + if (resultMt != null) { + MeasuredText.recycle(resultMt); + } } } @@ -1333,7 +1354,6 @@ public class TextUtils { * @return the formatted CharSequence. If even the shortest sequence (e.g. {@code "A, 11 more"}) * doesn't fit, it will return an empty string. */ - public static CharSequence listEllipsize(@Nullable Context context, @Nullable List elements, @NonNull String separator, @NonNull TextPaint paint, @FloatRange(from=0.0,fromInclusive=false) float avail,