Add renderer support for TEXT_OVERFLOW_ELLIPSIZE

This option should ellipsize text at the place where
it stops becoming visible, even if that means that
maxLines is not yet reached.

The existing TEXT_OVERFLOW_ELLIPSIZE_END will
ellipsize text but only on its last line. So if text
has maxLines set to 5 and only 2 of the are shown,
text won't have ... at the end of visible part.

Bug: 302531877
Test: Added
Relnote: "Renderer now supports TEXT_OVERFLOW_ELLIPSIZE option."
Change-Id: I7f085fb841240511cd136c1e095d9c2ff0d60205
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/TestCasesGenerator.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/TestCasesGenerator.java
index 0ccec7c..6c65576 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/TestCasesGenerator.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/TestCasesGenerator.java
@@ -334,6 +334,47 @@
         testCases.put(
                 "overflow_text_golden" + goldenSuffix,
                 new Text.Builder(context, "abcdeabcdeabcde").build());
+        testCases.put(
+                "overflow_ellipsize_maxlines_notreached" + goldenSuffix,
+                new Box.Builder()
+                        .setWidth(dp(100))
+                        .setHeight(dp(42))
+                        .setModifiers(buildBackgroundColorModifier(Color.YELLOW))
+                        .addContent(
+                                new Text.Builder(
+                                        context,
+                                        "Very long text that won't fit in its parent box so it"
+                                                + "needs to be ellipsized correctly before its "
+                                                + "last line")
+                                        // Line height = 20sp
+                                        .setTypography(Typography.TYPOGRAPHY_BODY1)
+                                        .setOverflow(LayoutElementBuilders.TEXT_OVERFLOW_ELLIPSIZE)
+                                        .setMultilineAlignment(
+                                                LayoutElementBuilders.TEXT_ALIGN_START)
+                                        .setMaxLines(6)
+                                        .build())
+                        .build());
+        testCases.put(
+                "overflow_ellipsize_end_maxlines_notreached" + goldenSuffix,
+                new Box.Builder()
+                        .setWidth(dp(100))
+                        .setHeight(dp(42))
+                        .setModifiers(buildBackgroundColorModifier(Color.YELLOW))
+                        .addContent(
+                                new Text.Builder(
+                                        context,
+                                        "Very long text that won't fit in its parent box so it"
+                                                + "needs to be ellipsized correctly before its "
+                                                + "last line")
+                                        // Line height = 20sp
+                                        .setTypography(Typography.TYPOGRAPHY_BODY1)
+                                        .setOverflow(
+                                                LayoutElementBuilders.TEXT_OVERFLOW_ELLIPSIZE_END)
+                                        .setMultilineAlignment(
+                                                LayoutElementBuilders.TEXT_ALIGN_START)
+                                        .setMaxLines(6)
+                                        .build())
+                        .build());
 
         return testCases;
     }
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/layout.proto b/wear/protolayout/protolayout-proto/src/main/proto/layout.proto
index 96fc634..223346e 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/layout.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/layout.proto
@@ -167,6 +167,8 @@
   // Note that, when this is used, the parent of the Text element this
   // corresponds to shouldn't have its width and height set to wrapped, as it
   // can lead to unexpected results.
+  // <p>Note that, on SpanText, this will behave exactly the same way as
+  // TEXT_OVERFLOW_ELLIPSIZE_END.
   TEXT_OVERFLOW_ELLIPSIZE = 4;
 }
 
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
index 993dd38..baf27ab 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
@@ -69,6 +69,7 @@
 import android.view.ViewGroup.LayoutParams;
 import android.view.ViewOutlineProvider;
 import android.view.ViewParent;
+import android.view.ViewTreeObserver.OnPreDrawListener;
 import android.view.animation.AlphaAnimation;
 import android.view.animation.AnimationSet;
 import android.view.animation.TranslateAnimation;
@@ -1873,13 +1874,12 @@
                 // A null TruncateAt disables adding an ellipsis.
                 return null;
             case TEXT_OVERFLOW_ELLIPSIZE_END:
+            case TEXT_OVERFLOW_ELLIPSIZE:
                 return TruncateAt.END;
             case TEXT_OVERFLOW_MARQUEE:
                 return TruncateAt.MARQUEE;
             case TEXT_OVERFLOW_UNDEFINED:
             case UNRECOGNIZED:
-                // TODO(b/302531877): Implement ellipsize.
-            case TEXT_OVERFLOW_ELLIPSIZE:
                 return TEXT_OVERFLOW_DEFAULT;
         }
 
@@ -2550,7 +2550,14 @@
         } else {
             textView.setMaxLines(TEXT_MAX_LINES_DEFAULT);
         }
-        applyTextOverflow(textView, text.getOverflow(), text.getMarqueeParameters());
+
+        TextOverflowProp overflow = text.getOverflow();
+        applyTextOverflow(textView, overflow, text.getMarqueeParameters());
+
+        if (overflow.getValue() == TextOverflow.TEXT_OVERFLOW_ELLIPSIZE
+                && !text.getText().hasDynamicValue()) {
+            adjustMaxLinesForEllipsize(textView);
+        }
 
         // Text auto size is not supported for dynamic text.
         boolean isAutoSizeAllowed = !(text.hasText() && text.getText().hasDynamicValue());
@@ -2642,6 +2649,52 @@
     }
 
     /**
+     * Sorts out what maxLines should be if the text could possibly be truncated before maxLines is
+     * reached.
+     *
+     * <p>Should be only called for the {@link TextOverflow#TEXT_OVERFLOW_ELLIPSIZE} option which
+     * ellipsizes the text even before the last line, if there's no space for all lines. This is
+     * different than what TEXT_OVERFLOW_ELLIPSIZE_END does, as that option just ellipsizes the last
+     * line of text.
+     */
+    private void adjustMaxLinesForEllipsize(@NonNull TextView textView) {
+        textView
+                .getViewTreeObserver()
+                .addOnPreDrawListener(
+                        new OnPreDrawListener() {
+                            @Override
+                            public boolean onPreDraw() {
+                                ViewParent maybeParent = textView.getParent();
+                                if (!(maybeParent instanceof View)) {
+                                    Log.d(
+                                            TAG,
+                                            "Couldn't adjust max lines for ellipsizing as"
+                                                    + "there's no View/ViewGroup parent.");
+                                    return false;
+                                }
+
+                                textView.getViewTreeObserver().removeOnPreDrawListener(this);
+
+                                View parent = (View) maybeParent;
+                                int availableHeight = parent.getHeight();
+                                int oneLineHeight = textView.getLineHeight();
+                                // This is what was set in proto, we shouldn't exceed it.
+                                int maxMaxLines = textView.getMaxLines();
+                                // Avoid having maxLines as 0 in case the space is really tight.
+                                int availableLines = max(availableHeight / oneLineHeight, 1);
+
+                                // Update only if changed.
+                                if (availableLines < maxMaxLines) {
+                                    textView.setMaxLines(availableLines);
+                                }
+
+                                // Cancel the current drawing pass.
+                                return false;
+                            }
+                        });
+    }
+
+    /**
      * Sets whether the padding is included or not. If font padding is not included, sets the
      * correct padding to the TextView to avoid clipping taller languages.
      */
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
index 4fa4807..950e73f 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
@@ -2765,6 +2765,70 @@
     }
 
     @Test
+    public void inflate_textView_ellipsize() {
+        String textContents = "Text that is very large so it will go to many lines";
+        Text.Builder text1 =
+                Text.newBuilder()
+                        .setLineHeight(sp(16))
+                        .setText(string(textContents))
+                        .setFontStyle(FontStyle.newBuilder().addSize(sp(16)))
+                        .setMaxLines(Int32Prop.newBuilder().setValue(6))
+                        .setOverflow(
+                                TextOverflowProp.newBuilder().setValue(
+                                        TextOverflow.TEXT_OVERFLOW_ELLIPSIZE));
+        Layout layout1 =
+                fingerprintedLayout(
+                        LayoutElement.newBuilder()
+                                .setBox(buildFixedSizeBoxWIthText(text1)).build());
+
+        Text.Builder text2 =
+                Text.newBuilder()
+                        .setText(string(textContents))
+                        // Diff
+                        .setLineHeight(sp(4))
+                        .setFontStyle(FontStyle.newBuilder().addSize(sp(4)))
+                        .setMaxLines(Int32Prop.newBuilder().setValue(6))
+                        .setOverflow(
+                                TextOverflowProp.newBuilder().setValue(
+                                        TextOverflow.TEXT_OVERFLOW_ELLIPSIZE));
+        Layout layout2 =
+                fingerprintedLayout(
+                        LayoutElement.newBuilder()
+                                .setBox(buildFixedSizeBoxWIthText(text2)).build());
+
+        // Initial layout.
+        Renderer renderer = renderer(layout1);
+        ViewGroup inflatedViewParent = renderer.inflate();
+        TextView textView1 = (TextView) ((ViewGroup) inflatedViewParent
+                .getChildAt(0)).getChildAt(0);
+
+        // Apply the mutation.
+        ViewGroupMutation mutation =
+                renderer.computeMutation(getRenderedMetadata(inflatedViewParent), layout2);
+        assertThat(mutation).isNotNull();
+        assertThat(mutation.isNoOp()).isFalse();
+        boolean mutationResult = renderer.applyMutation(inflatedViewParent, mutation);
+        assertThat(mutationResult).isTrue();
+
+        // This contains layout after the mutation.
+        TextView textView2 = (TextView) ((ViewGroup) inflatedViewParent
+                .getChildAt(0)).getChildAt(0);
+
+        expect.that(textView1.getEllipsize()).isEqualTo(TruncateAt.END);
+        expect.that(textView1.getMaxLines()).isEqualTo(2);
+
+        expect.that(textView2.getEllipsize()).isEqualTo(TruncateAt.END);
+        expect.that(textView2.getMaxLines()).isEqualTo(3);
+    }
+
+    private static Box.Builder buildFixedSizeBoxWIthText(Text.Builder content) {
+        return Box.newBuilder()
+                .setWidth(ContainerDimension.newBuilder().setLinearDimension(dp(100)))
+                .setHeight(ContainerDimension.newBuilder().setLinearDimension(dp(120)))
+                .addContents(LayoutElement.newBuilder().setText(content));
+    }
+
+    @Test
     public void inflate_textView_marquee_animationsDisabled() {
         String textContents = "Marquee Animation";
         LayoutElement root =
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
index 3e84390..ff9990c 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
@@ -222,6 +222,9 @@
      * parent container. Note that, when this is used, the parent of the {@link Text} element this
      * corresponds to shouldn't have its width and height set to wrapped, as it can lead to
      * unexpected results.
+     *
+     * <p>Note that, on {@link SpanText}, this will behave exactly the same way as
+     * TEXT_OVERFLOW_ELLIPSIZE_END.
      */
     @RequiresSchemaVersion(major = 1, minor = 300)
     public static final int TEXT_OVERFLOW_ELLIPSIZE = 4;