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

Commit e68d3cac authored by Hawkwood Glazier's avatar Hawkwood Glazier Committed by Android (Google) Code Review
Browse files

Merge "Adjust Clock Glyph X Positioning" into main

parents 96ed620f 8440190d
Loading
Loading
Loading
Loading
+149 −58
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import android.graphics.fonts.Font
import android.graphics.fonts.FontVariationAxis
import android.graphics.text.PositionedGlyphs
import android.text.Layout
import android.text.TextDirectionHeuristic
import android.text.TextPaint
import android.text.TextShaper
import android.util.MathUtils
@@ -28,18 +29,33 @@ import com.android.internal.graphics.ColorUtils
import java.lang.Math.max

interface TextInterpolatorListener {
    fun onPaintModified() {}
    fun onPaintModified(paint: Paint) {}

    fun onRebased() {}
    fun onRebased(progress: Float) {}

    fun getCharWidthAdjustment(font: Font, char: Char, width: Float): Float = 0f

    fun onTotalAdjustmentComputed(
        paint: Paint,
        lineAdvance: Float,
        totalAdjustment: Float,
    ): Boolean = false
}

class ShapingResult(
    val text: String,
    val lines: List<List<ShapingRun>>,
    val textDirectionHeuristic: TextDirectionHeuristic,
)

class ShapingRun(val text: String, val glyphs: PositionedGlyphs)

/** Provide text style linear interpolation for plain text. */
class TextInterpolator(
    layout: Layout,
    var typefaceCache: TypefaceVariantCache,
    private val listener: TextInterpolatorListener? = null,
) {

    /**
     * Returns base paint used for interpolation.
     *
@@ -147,7 +163,7 @@ class TextInterpolator(
     */
    fun onTargetPaintModified() {
        updatePositionsAndFonts(shapeText(layout, targetPaint), updateBase = false)
        listener?.onPaintModified()
        listener?.onPaintModified(targetPaint)
    }

    /**
@@ -158,7 +174,7 @@ class TextInterpolator(
     */
    fun onBasePaintModified() {
        updatePositionsAndFonts(shapeText(layout, basePaint), updateBase = true)
        listener?.onPaintModified()
        listener?.onPaintModified(basePaint)
    }

    /**
@@ -217,7 +233,7 @@ class TextInterpolator(
     */
    fun rebase() {
        if (progress == 0f) {
            listener?.onRebased()
            listener?.onRebased(progress)
            return
        } else if (progress == 1f) {
            basePaint.set(targetPaint)
@@ -246,9 +262,9 @@ class TextInterpolator(
            }
        }

        progress = 0f
        listener?.onRebased(progress)
        linearProgress = 0f
        listener?.onRebased()
        progress = 0f
    }

    /**
@@ -281,50 +297,67 @@ class TextInterpolator(
        val baseLayout = shapeText(layout, basePaint)
        val targetLayout = shapeText(layout, targetPaint)

        require(baseLayout.size == targetLayout.size) {
        require(baseLayout.lines.size == targetLayout.lines.size) {
            "The new layout result has different line count."
        }

        var maxRunLength = 0
        lines =
            baseLayout.zip(targetLayout) { baseLine, targetLine ->
            baseLayout.lines.zip(targetLayout.lines) { baseLine, targetLine ->
                val runs =
                    baseLine.zip(targetLine) { base, target ->
                        require(base.glyphCount() == target.glyphCount()) {
                        require(base.glyphs.glyphCount() == target.glyphs.glyphCount()) {
                            "Inconsistent glyph count at line ${lines.size}"
                        }

                        val glyphCount = base.glyphCount()
                        val glyphCount = base.glyphs.glyphCount()

                        // Good to recycle the array if the existing array can hold the new layout
                        // result.
                        val glyphIds =
                            IntArray(glyphCount) {
                                base.getGlyphId(it).also { baseGlyphId ->
                                    require(baseGlyphId == target.getGlyphId(it)) {
                                base.glyphs.getGlyphId(it).also { baseGlyphId ->
                                    require(baseGlyphId == target.glyphs.getGlyphId(it)) {
                                        "Inconsistent glyph ID at $it in line ${lines.size}"
                                    }
                                }
                            }

                        val baseX = FloatArray(glyphCount) { base.getGlyphX(it) }
                        val baseY = FloatArray(glyphCount) { base.getGlyphY(it) }
                        val targetX = FloatArray(glyphCount) { target.getGlyphX(it) }
                        val targetY = FloatArray(glyphCount) { target.getGlyphY(it) }
                        val baseX = FloatArray(glyphCount)
                        val baseY = FloatArray(glyphCount)
                        populateGlyphPositions(
                            basePaint,
                            baseLayout.textDirectionHeuristic,
                            base.glyphs,
                            base.text,
                            baseX,
                            baseY,
                        )

                        val targetX = FloatArray(glyphCount)
                        val targetY = FloatArray(glyphCount)
                        populateGlyphPositions(
                            targetPaint,
                            targetLayout.textDirectionHeuristic,
                            target.glyphs,
                            target.text,
                            targetX,
                            targetY,
                        )

                        // Calculate font runs
                        val fontRun = mutableListOf<FontRun>()
                        if (glyphCount != 0) {
                            var start = 0
                            var baseFont = base.getFont(start)
                            var targetFont = target.getFont(start)
                            var baseFont = base.glyphs.getFont(start)
                            var targetFont = target.glyphs.getFont(start)
                            require(FontInterpolator.canInterpolate(baseFont, targetFont)) {
                                "Cannot interpolate font at $start ($baseFont vs $targetFont)"
                            }

                            for (i in 1 until glyphCount) {
                                val nextBaseFont = base.getFont(i)
                                val nextTargetFont = target.getFont(i)
                                val nextBaseFont = base.glyphs.getFont(i)
                                val nextTargetFont = target.glyphs.getFont(i)

                                if (baseFont !== nextBaseFont) {
                                    require(targetFont !== nextTargetFont) {
@@ -446,28 +479,29 @@ class TextInterpolator(
        )
    }

    private fun updatePositionsAndFonts(
        layoutResult: List<List<PositionedGlyphs>>,
        updateBase: Boolean,
    ) {
    private fun updatePositionsAndFonts(layoutResult: ShapingResult, updateBase: Boolean) {
        // Update target positions with newly calculated text layout.
        check(layoutResult.size == lines.size) { "The new layout result has different line count." }
        check(layoutResult.lines.size == lines.size) {
            "The new layout result has different line count."
        }

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

                lineRun.fontRuns.forEach { run ->
                    val newFont = newGlyphs.getFont(run.start)
                    val newFont = newRun.glyphs.getFont(run.start)
                    for (i in run.start until run.end) {
                        require(newGlyphs.getGlyphId(run.start) == lineRun.glyphIds[run.start]) {
                        require(
                            newRun.glyphs.getGlyphId(run.start) == lineRun.glyphIds[run.start]
                        ) {
                            "The new layout has different glyph ID at ${run.start}"
                        }
                        require(newFont === newGlyphs.getFont(i)) {
                        require(newFont === newRun.glyphs.getFont(i)) {
                            "The new layout has different font run." +
                                " $newFont vs ${newGlyphs.getFont(i)} at $i"
                                " $newFont vs ${newRun.glyphs.getFont(i)} at $i"
                        }
                    }

@@ -486,15 +520,23 @@ class TextInterpolator(
                }

                if (updateBase) {
                    for (i in lineRun.baseX.indices) {
                        lineRun.baseX[i] = newGlyphs.getGlyphX(i)
                        lineRun.baseY[i] = newGlyphs.getGlyphY(i)
                    }
                    populateGlyphPositions(
                        basePaint,
                        layoutResult.textDirectionHeuristic,
                        newRun.glyphs,
                        newRun.text,
                        lineRun.baseX,
                        lineRun.baseY,
                    )
                } else {
                    for (i in lineRun.baseX.indices) {
                        lineRun.targetX[i] = newGlyphs.getGlyphX(i)
                        lineRun.targetY[i] = newGlyphs.getGlyphY(i)
                    }
                    populateGlyphPositions(
                        targetPaint,
                        layoutResult.textDirectionHeuristic,
                        newRun.glyphs,
                        newRun.text,
                        lineRun.targetX,
                        lineRun.targetY,
                    )
                }
            }
        }
@@ -512,9 +554,9 @@ class TextInterpolator(
    }

    // Shape the text and stores the result to out argument.
    private fun shapeText(layout: Layout, paint: TextPaint): List<List<PositionedGlyphs>> {
    private fun shapeText(layout: Layout, paint: TextPaint): ShapingResult {
        var text = StringBuilder()
        val out = mutableListOf<List<PositionedGlyphs>>()
        val lines = mutableListOf<List<ShapingRun>>()
        for (lineNo in 0 until layout.lineCount) { // Shape all lines.
            val lineStart = layout.getLineStart(lineNo)
            val lineEnd = layout.getLineEnd(lineNo)
@@ -525,31 +567,80 @@ class TextInterpolator(
                count--
            }

            val runs = mutableListOf<PositionedGlyphs>()
            val runs = mutableListOf<ShapingRun>()
            TextShaper.shapeText(
                layout.text,
                lineStart,
                count,
                layout.textDirectionHeuristic,
                paint,
            ) { _, _, glyphs, _ ->
                runs.add(glyphs)
            ) { start, count, glyphs, _ ->
                runs.add(ShapingRun(layout.text.substring(start, start + count), glyphs))
            }
            out.add(runs)
            lines.add(runs)

            if (lineNo > 0) {
                text.append("\n")
            }
            if (lineNo > 0) text.append("\n")
            text.append(layout.text.substring(lineStart, lineEnd))
        }
        shapedText = text.toString()
        return out
        return ShapingResult(shapedText, lines, layout.textDirectionHeuristic)
    }

    private fun populateGlyphPositions(
        paint: Paint,
        textDirectionHeuristic: TextDirectionHeuristic,
        glyphs: PositionedGlyphs,
        str: String,
        outX: FloatArray,
        outY: FloatArray,
    ) {
        val isRtl = textDirectionHeuristic.isRtl(str, 0, str.length)
        val range = (0 until glyphs.glyphCount()).let { if (isRtl) it.reversed() else it }
        val sign = if (isRtl) -1 else 1

        var xAdjustment = 0f
        for (i in range) {
            val xPos = glyphs.getGlyphX(i)
            outX[i] = xPos + xAdjustment * sign
            outY[i] = glyphs.getGlyphY(i)

            // Characters are left-aligned so any modifications to width only effect the positioning
            // of later characters. As a result, all we need to do is track a cumulative total. The
            // last character is skipped as the view bounds don't include it's trailing spacing.
            if (i != range.last()) {
                val font = glyphs.getFont(i)
                val nextXPos =
                    when {
                        i + 1 < glyphs.glyphCount() -> glyphs.getGlyphX(i + 1)
                        !isRtl -> glyphs.getAdvance()
                        else -> 0f
                    }
                xAdjustment += listener?.getCharWidthAdjustment(font, str[i], nextXPos - xPos) ?: 0f
            }
        }

private fun Layout.getDrawOrigin(lineNo: Int) =
        val boundsUpdated =
            listener?.onTotalAdjustmentComputed(paint, glyphs.getAdvance(), xAdjustment) ?: false

        // RTL glyph positions are relative to zero on the right side, but do not invert the x axis.
        // and as a result are negative. They are still however drawn relative to the left side of
        // the view. This means when we shrink the view, they'll end up mispositioned unless we
        // account for the total adjustment and update each glyph position. For some reason that
        // isn't clear this misalginment is only present in production and not in robolectric tests.
        if (isRtl && boundsUpdated) {
            for (i in range) {
                outX[i] -= xAdjustment
            }
        }
    }

    companion object {
        private fun Layout.getDrawOrigin(lineNo: Int): Float {
            if (getParagraphDirection(lineNo) == Layout.DIR_LEFT_TO_RIGHT) {
        getLineLeft(lineNo)
                return getLineLeft(lineNo)
            } else {
        getLineRight(lineNo)
                return getLineRight(lineNo)
            }
        }
    }
}
+72 −13
Original line number Diff line number Diff line
@@ -23,11 +23,13 @@ import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.Rect
import android.graphics.fonts.Font
import android.os.VibrationEffect
import android.text.TextPaint
import android.util.AttributeSet
import android.util.Log
import android.util.MathUtils.lerp
import android.util.MathUtils.lerpInvSat
import android.util.TypedValue
import android.view.View
import android.view.View.MeasureSpec.EXACTLY
@@ -59,6 +61,7 @@ import com.android.systemui.shared.clocks.DimensionParser
import com.android.systemui.shared.clocks.FLEX_CLOCK_ID
import com.android.systemui.shared.clocks.FontTextStyle
import java.lang.Thread
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@@ -72,6 +75,10 @@ private fun Paint.getTextBounds(text: CharSequence): VRectF {
    return VRectF(tempRect)
}

private fun nearEqual(a: Float, b: Float, tolerance: Float): Boolean {
    return abs(a - b) < tolerance
}

enum class VerticalAlignment {
    TOP,
    BOTTOM,
@@ -112,7 +119,7 @@ enum class XAlignment {
@SuppressLint("AppCompatCustomView")
open class SimpleDigitalClockTextView(
    val clockCtx: ClockContext,
    isLargeClock: Boolean,
    val isLargeClock: Boolean,
    attrs: AttributeSet? = null,
) : TextView(clockCtx.context, attrs) {
    val lockScreenPaint = TextPaint()
@@ -198,6 +205,53 @@ open class SimpleDigitalClockTextView(
    var measuredBaseline = 0
    var lockscreenColor = Color.WHITE
    var aodColor = Color.WHITE
    var baseWidthAdjustment = 0f
    var targetWidthAdjustment = 0f

    private val animatorListener =
        object : TextAnimatorListener {
            override fun onInvalidate() = invalidate()

            override fun onRebased(progress: Float) {
                baseWidthAdjustment = lerp(baseWidthAdjustment, targetWidthAdjustment, progress)
                updateAnimationTextBounds()
            }

            override fun onPaintModified(paint: Paint) {
                updateAnimationTextBounds()
            }

            override fun getCharWidthAdjustment(font: Font, char: Char, width: Float): Float {
                if (isLargeClock) return 0f
                val charMult = SPACING_ADJUSTMENT_GLYPH_MAP.get(char) ?: 1f
                val wdth = font.axes?.firstOrNull { it.tag == GSFAxes.WIDTH.tag }?.styleValue ?: 0f
                return width * SPACING_BASE_ADJUSTMENT * charMult * lerpInvSat(30f, 120f, wdth)
            }

            override fun onTotalAdjustmentComputed(
                paint: Paint,
                lineAdvance: Float,
                totalAdjustment: Float,
            ): Boolean {
                val isBasePaint = paint == textAnimator.textInterpolator.basePaint
                if (isBasePaint) {
                    if (!nearEqual(baseWidthAdjustment, totalAdjustment, 0.1f)) {
                        baseWidthAdjustment = totalAdjustment
                        updateAnimationTextBounds()
                    }
                } else {
                    if (!nearEqual(targetWidthAdjustment, totalAdjustment, 0.1f)) {
                        targetWidthAdjustment = totalAdjustment
                        updateAnimationTextBounds()
                    }
                }

                // If animation is disabled, then we don't want to adjust the glyph positions with
                // updated bounds as in the robolectric test environment we don't see the same
                // misalignment of RTL glyphs from the view bounds as we do in production.
                return isAnimationEnabled
            }
        }

    fun updateColor(lockscreenColor: Int, aodColor: Int = Color.WHITE) {
        this.lockscreenColor = lockscreenColor
@@ -269,18 +323,7 @@ open class SimpleDigitalClockTextView(
        val layout = this.layout
        if (layout != null) {
            if (!this::textAnimator.isInitialized) {
                textAnimator =
                    TextAnimator(
                        layout,
                        typefaceCache,
                        object : TextAnimatorListener {
                            override fun onInvalidate() = invalidate()

                            override fun onRebased() = updateAnimationTextBounds()

                            override fun onPaintModified() = updateAnimationTextBounds()
                        },
                    )
                textAnimator = TextAnimator(layout, typefaceCache, animatorListener)
                setInterpolatorPaint()
            } else {
                textAnimator.updateLayout(layout)
@@ -649,6 +692,15 @@ open class SimpleDigitalClockTextView(
        updateAnimationTextBounds()
    }

    private fun adjustSpacingBounds(rect: VRectF, adjustment: Float): VRectF {
        return VRectF(
            top = rect.top,
            bottom = rect.bottom,
            left = rect.left - if (isLayoutRtl()) adjustment else 0f,
            right = rect.right + if (isLayoutRtl()) 0f else adjustment,
        )
    }

    /**
     * Called after textAnimator.setTextStyle textAnimator.setTextStyle will update targetPaint, and
     * rebase if previous animator is canceled so basePaint will store the state we transition from
@@ -663,6 +715,9 @@ open class SimpleDigitalClockTextView(
            prevTextBounds = textBounds
            targetTextBounds = textBounds
        }

        prevTextBounds = adjustSpacingBounds(prevTextBounds, baseWidthAdjustment)
        targetTextBounds = adjustSpacingBounds(targetTextBounds, targetWidthAdjustment)
    }

    /**
@@ -725,6 +780,10 @@ open class SimpleDigitalClockTextView(
        private val FLEX_AOD_WIDTH_AXIS = GSFAxes.WIDTH to 43f
        private val FLEX_ROUND_AXIS = GSFAxes.ROUND to 100f

        // Multipliers for glyphs that need specific spacing adjustment
        private val SPACING_ADJUSTMENT_GLYPH_MAP = mapOf(':' to 2.5f, '1' to 3.0f)
        private val SPACING_BASE_ADJUSTMENT = -0.08f

        private fun fromAxes(vararg axes: Pair<AxisDefinition, Float>): ClockAxisStyle {
            return ClockAxisStyle(axes.map { (def, value) -> def.tag to value }.toMap())
        }
+1 −0
Original line number Diff line number Diff line
@@ -284,6 +284,7 @@ constructor(

        // Font axes width max cutoff
        // A font with a wider font axes than this is at risk of being pushed off screen
        // Value determined by the very robust and scientific process of eye-balling a few devices
        private const val FONT_WIDTH_MAX_CUTOFF = 110
    }
}