Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 110f1efd authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "fix(high contrast text): draw contrasting backgrounds when text changes...

Merge "fix(high contrast text): draw contrasting backgrounds when text changes color via Spans" into main
parents 6eda99a3 48a980e1
Loading
Loading
Loading
Loading
+72 −9
Original line number Diff line number Diff line
@@ -18,9 +18,10 @@ package android.text;

import static com.android.graphics.hwui.flags.Flags.highContrastTextLuminance;
import static com.android.text.flags.Flags.FLAG_FIX_LINE_HEIGHT_FOR_LOCALE;
import static com.android.text.flags.Flags.FLAG_USE_BOUNDS_FOR_WIDTH;
import static com.android.text.flags.Flags.FLAG_LETTER_SPACING_JUSTIFICATION;
import static com.android.text.flags.Flags.FLAG_USE_BOUNDS_FOR_WIDTH;

import android.annotation.ColorInt;
import android.annotation.FlaggedApi;
import android.annotation.FloatRange;
import android.annotation.IntDef;
@@ -398,6 +399,20 @@ public abstract class Layout {
        mUseBoundsForWidth = useBoundsForWidth;
        mShiftDrawingOffsetForStartOverhang = shiftDrawingOffsetForStartOverhang;
        mMinimumFontMetrics = minimumFontMetrics;

        initSpanColors();
    }

    private void initSpanColors() {
        if (mSpannedText && Flags.highContrastTextSmallTextRect()) {
            if (mSpanColors == null) {
                mSpanColors = new SpanColors();
            } else {
                mSpanColors.recycle();
            }
        } else {
            mSpanColors = null;
        }
    }

    /**
@@ -417,6 +432,7 @@ public abstract class Layout {
        mSpacingMult = spacingmult;
        mSpacingAdd = spacingadd;
        mSpannedText = text instanceof Spanned;
        initSpanColors();
    }

    /**
@@ -643,20 +659,20 @@ public abstract class Layout {
            return null;
        }

        return isHighContrastTextDark() ? BlendMode.MULTIPLY : BlendMode.DIFFERENCE;
        return isHighContrastTextDark(mPaint.getColor()) ? BlendMode.MULTIPLY
                : BlendMode.DIFFERENCE;
    }

    private boolean isHighContrastTextDark() {
    private boolean isHighContrastTextDark(@ColorInt int color) {
        // High-contrast text mode
        // Determine if the text is black-on-white or white-on-black, so we know what blendmode will
        // give the highest contrast and most realistic text color.
        // This equation should match the one in libs/hwui/hwui/DrawTextFunctor.h
        if (highContrastTextLuminance()) {
            var lab = new double[3];
            ColorUtils.colorToLAB(mPaint.getColor(), lab);
            return lab[0] < 0.5;
            ColorUtils.colorToLAB(color, lab);
            return lab[0] < 50.0;
        } else {
            var color = mPaint.getColor();
            int channelSum = Color.red(color) + Color.green(color) + Color.blue(color);
            return channelSum < (128 * 3);
        }
@@ -1010,15 +1026,22 @@ public abstract class Layout {
        var padding = Math.max(HIGH_CONTRAST_TEXT_BORDER_WIDTH_MIN_PX,
                mPaint.getTextSize() * HIGH_CONTRAST_TEXT_BORDER_WIDTH_FACTOR);

        var originalTextColor = mPaint.getColor();
        var bgPaint = mWorkPlainPaint;
        bgPaint.reset();
        bgPaint.setColor(isHighContrastTextDark() ? Color.WHITE : Color.BLACK);
        bgPaint.setColor(isHighContrastTextDark(originalTextColor) ? Color.WHITE : Color.BLACK);
        bgPaint.setStyle(Paint.Style.FILL);

        int start = getLineStart(firstLine);
        int end = getLineEnd(lastLine);
        // Draw a separate background rectangle for each line of text, that only surrounds the
        // characters on that line.
        // characters on that line. But we also have to check the text color for each character, and
        // make sure we are drawing the correct contrasting background. This is because Spans can
        // change colors throughout the text and we'll need to match our backgrounds.
        if (mSpannedText && mSpanColors != null) {
            mSpanColors.init(mWorkPaint, ((Spanned) mText), start, end);
        }

        forEachCharacterBounds(
                start,
                end,
@@ -1028,13 +1051,24 @@ public abstract class Layout {
                    int mLastLineNum = -1;
                    final RectF mLineBackground = new RectF();

                    @ColorInt int mLastColor = originalTextColor;

                    @Override
                    public void onCharacterBounds(int index, int lineNum, float left, float top,
                            float right, float bottom) {
                        if (lineNum != mLastLineNum) {

                        var newBackground = determineContrastingBackgroundColor(index);
                        var hasBgColorChanged = newBackground != bgPaint.getColor();

                        if (lineNum != mLastLineNum || hasBgColorChanged) {
                            // Draw what we have so far, then reset the rect and update its color
                            drawRect();
                            mLineBackground.set(left, top, right, bottom);
                            mLastLineNum = lineNum;

                            if (hasBgColorChanged) {
                                bgPaint.setColor(newBackground);
                            }
                        } else {
                            mLineBackground.union(left, top, right, bottom);
                        }
@@ -1051,8 +1085,36 @@ public abstract class Layout {
                            canvas.drawRect(mLineBackground, bgPaint);
                        }
                    }

                    private int determineContrastingBackgroundColor(int index) {
                        if (!mSpannedText || mSpanColors == null) {
                            // The text is not Spanned. it's all one color.
                            return bgPaint.getColor();
                        }

                        // Sometimes the color will change, but not enough to warrant a background
                        // color change. e.g. from black to dark grey still gets clamped to black,
                        // so the background stays white and we don't need to draw a fresh
                        // background.
                        var textColor = mSpanColors.getColorAt(index);
                        if (textColor == SpanColors.NO_COLOR_FOUND) {
                            textColor = originalTextColor;
                        }
                        var hasColorChanged = textColor != mLastColor;
                        if (hasColorChanged) {
                            mLastColor = textColor;

                            return isHighContrastTextDark(textColor) ? Color.WHITE : Color.BLACK;
                        }

                        return bgPaint.getColor();
                    }
                }
        );

        if (mSpanColors != null) {
            mSpanColors.recycle();
        }
    }

    /**
@@ -3580,6 +3642,7 @@ public abstract class Layout {
    private float mSpacingAdd;
    private static final Rect sTempRect = new Rect();
    private boolean mSpannedText;
    @Nullable private SpanColors mSpanColors;
    private TextDirectionHeuristic mTextDir;
    private SpanSet<LineBackgroundSpan> mLineBackgroundSpans;
    private boolean mIncludePad;
+89 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.text;

import android.annotation.ColorInt;
import android.annotation.Nullable;
import android.graphics.Color;
import android.text.style.CharacterStyle;

/**
 * Finds the foreground text color for the given Spanned text so you can iterate through each color
 * change.
 *
 * @hide
 */
public class SpanColors {
    public static final @ColorInt int NO_COLOR_FOUND = Color.TRANSPARENT;

    private final SpanSet<CharacterStyle> mCharacterStyleSpanSet =
            new SpanSet<>(CharacterStyle.class);
    @Nullable private TextPaint mWorkPaint;

    public SpanColors() {}

    /**
     * Init for the given text
     *
     * @param workPaint A temporary TextPaint object that will be used to calculate the colors. The
     *                  paint properties will be mutated on calls to {@link #getColorAt(int)} so
     *                  make sure to reset it before you use it for something else.
     * @param spanned the text to examine
     * @param start index to start at
     * @param end index of the end
     */
    public void init(TextPaint workPaint, Spanned spanned, int start, int end) {
        mWorkPaint = workPaint;
        mCharacterStyleSpanSet.init(spanned, start, end);
    }

    /**
     * Removes all internal references to the spans to avoid memory leaks.
     */
    public void recycle() {
        mWorkPaint = null;
        mCharacterStyleSpanSet.recycle();
    }

    /**
     * Calculates the foreground color of the text at the given character index.
     *
     * <p>You must call {@link #init(TextPaint, Spanned, int, int)} before calling this
     */
    public @ColorInt int getColorAt(int index) {
        var finalColor = NO_COLOR_FOUND;
        // Reset the paint so if we get a CharacterStyle that doesn't actually specify color,
        // (like UnderlineSpan), we still return no color found.
        mWorkPaint.setColor(finalColor);
        for (int k = 0; k < mCharacterStyleSpanSet.numberOfSpans; k++) {
            if ((index >= mCharacterStyleSpanSet.spanStarts[k])
                    && (index <= mCharacterStyleSpanSet.spanEnds[k])) {
                final CharacterStyle span = mCharacterStyleSpanSet.spans[k];
                span.updateDrawState(mWorkPaint);

                finalColor = calculateFinalColor(mWorkPaint);
            }
        }
        return finalColor;
    }

    private @ColorInt int calculateFinalColor(TextPaint workPaint) {
        // TODO: can we figure out what the getColorFilter() will do?
        //  if so, we also need to reset colorFilter before the loop in getColorAt()
        return workPaint.getColor();
    }
}
+78 −0
Original line number Diff line number Diff line
@@ -39,6 +39,7 @@ import android.platform.test.annotations.RequiresFlagsEnabled;
import android.platform.test.flag.junit.CheckFlagsRule;
import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.text.Layout.Alignment;
import android.text.style.ForegroundColorSpan;
import android.text.style.StrikethroughSpan;

import androidx.test.filters.SmallTest;
@@ -933,6 +934,83 @@ public class LayoutTest {
        expect.that(numBackgroundsFound).isEqualTo(backgroundRectsDrawn);
    }

    @Test
    @RequiresFlagsEnabled(FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void highContrastTextEnabled_testDrawMulticolorText_drawsBlackAndWhiteBackgrounds() {
        /*
        Here's what the final render should look like:

       Text  |   Background
     ========================
        al   |    BW
        w    |    WW
        ei   |    WW
        \t;  |    WW
        s    |    BB
        df   |    BB
        s    |    BB
        df   |    BB
        @    |    BB
      ------------------------
         */

        mTextPaint.setColor(Color.WHITE);

        mSpannedText.setSpan(
                // Can't use DKGREY because it is right on the cusp of clamping white
                new ForegroundColorSpan(0xFF332211),
                /* start= */ 1,
                /* end= */ 6,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE
        );
        mSpannedText.setSpan(
                new ForegroundColorSpan(Color.LTGRAY),
                /* start= */ 8,
                /* end= */ 11,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE
        );
        Layout layout = new StaticLayout(mSpannedText, mTextPaint, mWidth,
                mAlign, mSpacingMult, mSpacingAdd, /* includePad= */ false);

        final int width = 256;
        final int height = 256;
        MockCanvas c = new MockCanvas(width, height);
        c.setHighContrastTextEnabled(true);
        layout.draw(
                c,
                /* highlightPaths= */ null,
                /* highlightPaints= */ null,
                /* selectionPath= */ null,
                /* selectionPaint= */ null,
                /* cursorOffsetVertical= */ 0
        );
        List<MockCanvas.DrawCommand> drawCommands = c.getDrawCommands();
        var highlightsDrawn = 0;
        var numColorChangesWithinOneLine = 1;
        var textsDrawn = STATIC_LINE_COUNT + numColorChangesWithinOneLine;
        var backgroundRectsDrawn = STATIC_LINE_COUNT + numColorChangesWithinOneLine;
        expect.withMessage("wrong number of drawCommands: " + drawCommands)
                .that(drawCommands.size())
                .isEqualTo(textsDrawn + backgroundRectsDrawn + highlightsDrawn);

        var backgroundCommands = drawCommands.stream()
                .filter(it -> it.rect != null)
                .toList();

        expect.that(backgroundCommands.get(0).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(1).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(2).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(3).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(4).paint.getColor()).isEqualTo(Color.WHITE);
        expect.that(backgroundCommands.get(5).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(6).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(7).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(8).paint.getColor()).isEqualTo(Color.BLACK);
        expect.that(backgroundCommands.get(9).paint.getColor()).isEqualTo(Color.BLACK);

        expect.that(backgroundCommands.size()).isEqualTo(backgroundRectsDrawn);
    }

    private static final class MockCanvas extends Canvas {

        static class DrawCommand {
+78 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.text;

import static com.google.common.truth.Truth.assertThat;

import android.graphics.Color;
import android.graphics.drawable.ShapeDrawable;
import android.platform.test.annotations.Presubmit;
import android.text.style.ForegroundColorSpan;
import android.text.style.ImageSpan;
import android.text.style.UnderlineSpan;

import androidx.test.filters.SmallTest;
import androidx.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

@Presubmit
@SmallTest
@RunWith(AndroidJUnit4.class)
public class SpanColorsTest {
    private final TextPaint mWorkPaint = new TextPaint();
    private SpanColors mSpanColors;
    private SpannableString mSpannedText;

    @Before
    public void setup() {
        mSpanColors = new SpanColors();
        mSpannedText = new SpannableString("Hello world! This is a test.");
        mSpannedText.setSpan(new ForegroundColorSpan(Color.RED), 0, 4,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new ForegroundColorSpan(Color.GREEN), 6, 11,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new UnderlineSpan(), 5, 10, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new ImageSpan(new ShapeDrawable()), 1, 2,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        mSpannedText.setSpan(new ForegroundColorSpan(Color.BLUE), 12, 16,
                Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
    }

    @Test
    public void testNoColorFound() {
        mSpanColors.init(mWorkPaint, mSpannedText, 25, 30); // Beyond the spans
        assertThat(mSpanColors.getColorAt(27)).isEqualTo(SpanColors.NO_COLOR_FOUND);
    }

    @Test
    public void testSingleColorSpan() {
        mSpanColors.init(mWorkPaint, mSpannedText, 1, 4);
        assertThat(mSpanColors.getColorAt(3)).isEqualTo(Color.RED);
    }

    @Test
    public void testMultipleColorSpans() {
        mSpanColors.init(mWorkPaint, mSpannedText, 0, mSpannedText.length());
        assertThat(mSpanColors.getColorAt(2)).isEqualTo(Color.RED);
        assertThat(mSpanColors.getColorAt(5)).isEqualTo(SpanColors.NO_COLOR_FOUND);
        assertThat(mSpanColors.getColorAt(8)).isEqualTo(Color.GREEN);
        assertThat(mSpanColors.getColorAt(13)).isEqualTo(Color.BLUE);
    }
}