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

Commit f4aef967 authored by Seigo Nonaka's avatar Seigo Nonaka
Browse files

Add BiDi support to TextInterpolator

Bug: 174206981
Test: atest FontInterpolatorTest
Test: atest TextInterpolatorTest
Test: atest KeyguardClockSwitchTest
Test: atest TextAnimatorTest
Change-Id: Ibc8af6118be9f20b00210f3dd06f18f7ef688811
parent 298aecf4
Loading
Loading
Loading
Loading
+143 −122
Original line number Diff line number Diff line
@@ -19,8 +19,9 @@ import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.fonts.Font
import android.graphics.text.PositionedGlyphs
import android.graphics.text.TextRunShaper
import android.text.Layout
import android.text.TextPaint
import android.text.TextShaper
import android.util.MathUtils
import com.android.internal.graphics.ColorUtils
import java.lang.Math.max
@@ -57,10 +58,10 @@ class TextInterpolator(
     */
    val targetPaint = createDefaultPaint(layout.paint, lines)

    private fun createDefaultPaint(paint: Paint, lines: Int): ArrayList<Paint> {
        val paintList = ArrayList<Paint>()
    private fun createDefaultPaint(paint: TextPaint, lines: Int): ArrayList<TextPaint> {
        val paintList = ArrayList<TextPaint>()
        for (i in 0 until lines)
            paintList.add(Paint(paint))
            paintList.add(TextPaint(paint))
        return paintList
    }

@@ -79,9 +80,9 @@ class TextInterpolator(
    }

    /**
     * A class represents text layout of a single line.
     * A class represents text layout of a single run.
     */
    private class Line(
    private class Run(
        val glyphIds: IntArray,
        val baseX: FloatArray, // same length as glyphIds
        val baseY: FloatArray, // same length as glyphIds
@@ -90,11 +91,18 @@ class TextInterpolator(
        val fontRuns: List<FontRun>
    )

    /**
     * A class represents text layout of a single line.
     */
    private class Line(
        val runs: List<Run>
    )

    private var lines = listOf<Line>()
    private val fontInterpolator = FontInterpolator()

    // Recycling object for glyph drawing. Will be extended for the longest font run if needed.
    private val tmpDrawPaints = ArrayList<Paint>()
    private val tmpDrawPaints = ArrayList<TextPaint>()
    private var tmpPositionArray = FloatArray(20)

    /**
@@ -215,23 +223,25 @@ class TextInterpolator(
        }

        lines.forEach { line ->
            for (i in line.baseX.indices) {
                line.baseX[i] = MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
                line.baseY[i] = MathUtils.lerp(line.baseY[i], line.targetY[i], progress)
            line.runs.forEach { run ->
                for (i in run.baseX.indices) {
                    run.baseX[i] = MathUtils.lerp(run.baseX[i], run.targetX[i], progress)
                    run.baseY[i] = MathUtils.lerp(run.baseY[i], run.targetY[i], progress)
                }
            line.fontRuns.forEach {
                run.fontRuns.forEach {
                    it.baseFont = fontInterpolator.lerp(it.baseFont, it.targetFont, progress)
                }
            }
        }

        progress = 0f
    }

    companion object {
        fun updatePaint(toUpdate: ArrayList<Paint>, newValues: ArrayList<Paint>) {
        fun updatePaint(toUpdate: ArrayList<TextPaint>, newValues: ArrayList<TextPaint>) {
            toUpdate.clear()
            for (paint in newValues)
                toUpdate.add(Paint(paint))
                toUpdate.add(TextPaint(paint))
        }
    }

@@ -243,23 +253,25 @@ class TextInterpolator(
    fun draw(canvas: Canvas) {
        lerp(basePaint, targetPaint, progress, tmpDrawPaints)
        lines.forEachIndexed { lineNo, line ->
            line.runs.forEach { run ->
                canvas.save()
                try {
                    // Move to drawing origin.
                    val origin = layout.getDrawOrigin(lineNo)
                    canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat())

                line.fontRuns.forEach { run ->
                    run.fontRuns.forEach { fontRun ->
                        if (lineNo >= tmpDrawPaints.size)
                        drawFontRun(canvas, line, run, tmpDrawPaints[0])
                            drawFontRun(canvas, run, fontRun, tmpDrawPaints[0])
                        else
                        drawFontRun(canvas, line, run, tmpDrawPaints[lineNo])
                            drawFontRun(canvas, run, fontRun, tmpDrawPaints[lineNo])
                    }
                } finally {
                    canvas.restore()
                }
            }
        }
    }

    // Shape text with current paint parameters.
    private fun shapeText(layout: Layout) {
@@ -271,7 +283,9 @@ class TextInterpolator(
        }

        var maxRunLength = 0
        lines = baseLayout.zip(targetLayout) { base, target ->
        lines = baseLayout.zip(targetLayout) { baseLine, targetLine ->
            val runs = baseLine.zip(targetLine) { base, target ->

                require(base.glyphCount() == target.glyphCount()) {
                    "Inconsistent glyph count at line ${lines.size}"
                }
@@ -328,7 +342,9 @@ class TextInterpolator(
                    fontRun.add(FontRun(start, glyphCount, baseFont, targetFont))
                    maxRunLength = max(maxRunLength, glyphCount - start)
                }
            Line(glyphIds, baseX, baseY, targetX, targetY, fontRun)
                Run(glyphIds, baseX, baseY, targetX, targetY, fontRun)
            }
            Line(runs)
        }

        // Update float array used for drawing.
@@ -338,7 +354,7 @@ class TextInterpolator(
    }

    // Draws single font run.
    private fun drawFontRun(c: Canvas, line: Line, run: FontRun, paint: Paint) {
    private fun drawFontRun(c: Canvas, line: Run, run: FontRun, paint: Paint) {
        var arrayIndex = 0
        for (i in run.start until run.end) {
            tmpPositionArray[arrayIndex++] =
@@ -358,7 +374,7 @@ class TextInterpolator(
    }

    private fun updatePositionsAndFonts(
        layoutResult: List<PositionedGlyphs>,
        layoutResult: List<List<PositionedGlyphs>>,
        updateBase: Boolean
    ) {
        // Update target positions with newly calculated text layout.
@@ -366,15 +382,16 @@ class TextInterpolator(
            "The new layout result has different line count."
        }

        lines.zip(layoutResult) { line, newGlyphs ->
            require(newGlyphs.glyphCount() == line.glyphIds.size) {
        lines.zip(layoutResult) { line, runs ->
            line.runs.zip(runs) { lineRun, newGlyphs ->
                require(newGlyphs.glyphCount() == lineRun.glyphIds.size) {
                    "The new layout has different glyph count."
                }

            line.fontRuns.forEach { run ->
                lineRun.fontRuns.forEach { run ->
                    val newFont = newGlyphs.getFont(run.start)
                    for (i in run.start until run.end) {
                    require(newGlyphs.getGlyphId(run.start) == line.glyphIds[run.start]) {
                        require(newGlyphs.getGlyphId(run.start) == lineRun.glyphIds[run.start]) {
                            "The new layout has different glyph ID at ${run.start}"
                        }
                        require(newFont === newGlyphs.getFont(i)) {
@@ -383,10 +400,11 @@ class TextInterpolator(
                        }
                    }

                // The passing base font and target font is already interpolatable, so just check
                // new font can be interpolatable with base font.
                    // The passing base font and target font is already interpolatable, so just
                    // check new font can be interpolatable with base font.
                    require(FontInterpolator.canInterpolate(newFont, run.baseFont)) {
                    "New font cannot be interpolated with existing font. $newFont, ${run.baseFont}"
                        "New font cannot be interpolated with existing font. $newFont," +
                                " ${run.baseFont}"
                    }

                    if (updateBase) {
@@ -397,14 +415,15 @@ class TextInterpolator(
                }

                if (updateBase) {
                for (i in line.baseX.indices) {
                    line.baseX[i] = newGlyphs.getGlyphX(i)
                    line.baseY[i] = newGlyphs.getGlyphY(i)
                    for (i in lineRun.baseX.indices) {
                        lineRun.baseX[i] = newGlyphs.getGlyphX(i)
                        lineRun.baseY[i] = newGlyphs.getGlyphY(i)
                    }
                } else {
                for (i in line.baseX.indices) {
                    line.targetX[i] = newGlyphs.getGlyphX(i)
                    line.targetY[i] = newGlyphs.getGlyphY(i)
                    for (i in lineRun.baseX.indices) {
                        lineRun.targetX[i] = newGlyphs.getGlyphX(i)
                        lineRun.targetY[i] = newGlyphs.getGlyphY(i)
                    }
                }
            }
        }
@@ -412,16 +431,16 @@ class TextInterpolator(

    // Linear interpolate the paint.
    private fun lerp(
        from: ArrayList<Paint>,
        to: ArrayList<Paint>,
        from: ArrayList<TextPaint>,
        to: ArrayList<TextPaint>,
        progress: Float,
        out: ArrayList<Paint>
        out: ArrayList<TextPaint>
    ) {
        out.clear()
        // Currently only font size & colors are interpolated.
        // TODO(172943390): Add other interpolation or support custom interpolator.
        for (index in from.indices) {
            val paint = Paint(from[index])
            val paint = TextPaint(from[index])
            paint.textSize = MathUtils.lerp(from[index].textSize, to[index].textSize, progress)
            paint.color = ColorUtils.blendARGB(from[index].color, to[index].color, progress)
            out.add(paint)
@@ -429,18 +448,20 @@ class TextInterpolator(
    }

    // Shape the text and stores the result to out argument.
    private fun shapeText(layout: Layout, paints: ArrayList<Paint>): List<PositionedGlyphs> {
        val out = mutableListOf<PositionedGlyphs>()
    private fun shapeText(
        layout: Layout,
        paints: ArrayList<TextPaint>
    ): List<List<PositionedGlyphs>> {
        val out = mutableListOf<List<PositionedGlyphs>>()
        for (lineNo in 0 until layout.lineCount) { // Shape all lines.
            val lineStart = layout.getLineStart(lineNo)
            val count = layout.getLineEnd(lineNo) - lineStart
            out.add(TextRunShaper.shapeTextRun(
                    layout.text, // Styles are ignored.
                    lineStart, count, // shape range
                    lineStart, count, // shape context = shape range.
                    0f, 0f, // the layout offset. Not changed.
                    layout.getParagraphDirection(lineNo) == Layout.DIR_RIGHT_TO_LEFT,
                    paints[lineNo])) // Use given paint instead of layout's for style interpolation.
            val runs = mutableListOf<PositionedGlyphs>()
            TextShaper.shapeText(layout.text, lineStart, count, layout.textDirectionHeuristic,
                    paints[lineNo]) { _, _, glyphs, _ ->
                runs.add(glyphs)
            }
            out.add(runs)
        }
        return out
    }
+2 −3
Original line number Diff line number Diff line
@@ -17,7 +17,6 @@
package com.android.keyguard

import android.animation.ValueAnimator
import android.graphics.Paint
import android.testing.AndroidTestingRunner
import android.text.Layout
import android.text.StaticLayout
@@ -53,7 +52,7 @@ class TextAnimatorTest : SysuiTestCase() {
        val layout = makeLayout("Hello, World", PAINT[0])
        val valueAnimator = mock(ValueAnimator::class.java)
        val textInterpolator = mock(TextInterpolator::class.java)
        val paint = arrayListOf(mock(Paint::class.java))
        val paint = arrayListOf(mock(TextPaint::class.java))
        `when`(textInterpolator.targetPaint).thenReturn(paint)

        val textAnimator = TextAnimator(layout, {}).apply {
@@ -85,7 +84,7 @@ class TextAnimatorTest : SysuiTestCase() {
        val layout = makeLayout("Hello, World", PAINT[0])
        val valueAnimator = mock(ValueAnimator::class.java)
        val textInterpolator = mock(TextInterpolator::class.java)
        val paint = arrayListOf(mock(Paint::class.java))
        val paint = arrayListOf(mock(TextPaint::class.java))
        `when`(textInterpolator.targetPaint).thenReturn(paint)

        val textAnimator = TextAnimator(layout, {}).apply {
+58 −11
Original line number Diff line number Diff line
@@ -18,11 +18,12 @@ package com.android.keyguard

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.testing.AndroidTestingRunner
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.text.TextDirectionHeuristic
import android.text.TextDirectionHeuristics
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
@@ -31,6 +32,7 @@ import org.junit.runner.RunWith
import kotlin.math.ceil

private const val TEXT = "Hello, World."
private const val BIDI_TEXT = "abc\u05D0\u05D1\u05D2"
private const val BMP_WIDTH = 400
private const val BMP_HEIGHT = 300

@@ -38,11 +40,11 @@ private val PAINT = TextPaint().apply {
    textSize = 32f
}

private val START_PAINT = arrayListOf<Paint>(TextPaint(PAINT).apply {
private val START_PAINT = arrayListOf(TextPaint(PAINT).apply {
    fontVariationSettings = "'wght' 400"
})

private val END_PAINT = arrayListOf<Paint>(TextPaint(PAINT).apply {
private val END_PAINT = arrayListOf(TextPaint(PAINT).apply {
    fontVariationSettings = "'wght' 700"
})

@@ -50,9 +52,14 @@ private val END_PAINT = arrayListOf<Paint>(TextPaint(PAINT).apply {
@SmallTest
class TextInterpolatorTest : SysuiTestCase() {

    private fun makeLayout(text: String, paint: TextPaint): Layout {
    private fun makeLayout(
        text: String,
        paint: TextPaint,
        dir: TextDirectionHeuristic = TextDirectionHeuristics.LTR
    ): Layout {
        val width = ceil(Layout.getDesiredWidth(text, 0, text.length, paint)).toInt()
        return StaticLayout.Builder.obtain(text, 0, text.length, paint, width).build()
        return StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
                .setTextDirection(dir).build()
    }

    @Test
@@ -69,7 +76,7 @@ class TextInterpolatorTest : SysuiTestCase() {
        // Just after created TextInterpolator, it should have 0 progress.
        assertThat(interp.progress).isEqualTo(0f)
        val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
        val expected = makeLayout(TEXT, START_PAINT[0] as TextPaint).toBitmap(BMP_WIDTH, BMP_HEIGHT)
        val expected = makeLayout(TEXT, START_PAINT[0]).toBitmap(BMP_WIDTH, BMP_HEIGHT)

        assertThat(expected.sameAs(actual)).isTrue()
    }
@@ -87,7 +94,7 @@ class TextInterpolatorTest : SysuiTestCase() {

        interp.progress = 1f
        val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
        val expected = makeLayout(TEXT, END_PAINT[0] as TextPaint).toBitmap(BMP_WIDTH, BMP_HEIGHT)
        val expected = makeLayout(TEXT, END_PAINT[0]).toBitmap(BMP_WIDTH, BMP_HEIGHT)

        assertThat(expected.sameAs(actual)).isTrue()
    }
@@ -108,9 +115,9 @@ class TextInterpolatorTest : SysuiTestCase() {
        // end state.
        interp.progress = 0.5f
        val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
        assertThat(actual.sameAs(makeLayout(TEXT, START_PAINT[0] as TextPaint)
        assertThat(actual.sameAs(makeLayout(TEXT, START_PAINT[0])
            .toBitmap(BMP_WIDTH, BMP_HEIGHT))).isFalse()
        assertThat(actual.sameAs(makeLayout(TEXT, END_PAINT[0] as TextPaint)
        assertThat(actual.sameAs(makeLayout(TEXT, END_PAINT[0])
            .toBitmap(BMP_WIDTH, BMP_HEIGHT))).isFalse()
    }

@@ -135,10 +142,50 @@ class TextInterpolatorTest : SysuiTestCase() {

        assertThat(expected.sameAs(actual)).isTrue()
    }

    @Test
    fun testBidi_LTR() {
        val layout = makeLayout(BIDI_TEXT, PAINT, TextDirectionHeuristics.LTR)

        val interp = TextInterpolator(layout)
        TextInterpolator.updatePaint(interp.basePaint, START_PAINT)
        interp.onBasePaintModified()

        TextInterpolator.updatePaint(interp.targetPaint, END_PAINT)
        interp.onTargetPaintModified()

        // Just after created TextInterpolator, it should have 0 progress.
        assertThat(interp.progress).isEqualTo(0f)
        val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
        val expected = makeLayout(BIDI_TEXT, START_PAINT[0], TextDirectionHeuristics.LTR)
                .toBitmap(BMP_WIDTH, BMP_HEIGHT)

        assertThat(expected.sameAs(actual)).isTrue()
    }

    @Test
    fun testBidi_RTL() {
        val layout = makeLayout(BIDI_TEXT, PAINT, TextDirectionHeuristics.RTL)

        val interp = TextInterpolator(layout)
        TextInterpolator.updatePaint(interp.basePaint, START_PAINT)
        interp.onBasePaintModified()

        TextInterpolator.updatePaint(interp.targetPaint, END_PAINT)
        interp.onTargetPaintModified()

        // Just after created TextInterpolator, it should have 0 progress.
        assertThat(interp.progress).isEqualTo(0f)
        val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
        val expected = makeLayout(BIDI_TEXT, START_PAINT[0], TextDirectionHeuristics.RTL)
                .toBitmap(BMP_WIDTH, BMP_HEIGHT)

        assertThat(expected.sameAs(actual)).isTrue()
    }
}

private fun Layout.toBitmap(width: Int, height: Int) =
        Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8).also { draw(Canvas(it)) }!!
        Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { draw(Canvas(it)) }!!

private fun TextInterpolator.toBitmap(width: Int, height: Int) =
        Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8).also { draw(Canvas(it)) }
 No newline at end of file
        Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { draw(Canvas(it)) }
 No newline at end of file