BasicCurvedText.kt

/*
 * Copyright 2021 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 androidx.wear.compose.foundation

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

/**
 * Apply additional space along each edge of the content in [Dp].
 * See the [ArcPaddingValues] factories for convenient ways to
 * build [ArcPaddingValues].
 */
@Stable
public interface ArcPaddingValues {
    /**
     * Padding in the outward direction from the center of the [CurvedLayout]
     */
    fun calculateOuterPadding(): Dp

    /**
     * Padding in the inwards direction towards the center of the [CurvedLayout]
     */
    fun calculateInnerPadding(): Dp

    /**
     * Padding added at the start of the component.
     */
    fun calculateStartPadding(): Dp

    /**
     * Padding added at the end of the component.
     */
    fun calculateEndPadding(): Dp
}

/**
 * Apply additional space along each edge of the content in [Dp]. Note that the start and end
 * edges will be determined by the direction (clockwise or counterclockwise)
 *
 * @param outer Padding in the outward direction from the center of the
 * [CurvedLayout]
 * @param inner Padding in the inwards direction towards the center of the [CurvedLayout]
 * @param start Padding added at the start of the component.
 * @param end Padding added at the end of the component.
 */
public fun ArcPaddingValues(
    outer: Dp = 0.dp,
    inner: Dp = 0.dp,
    start: Dp = 0.dp,
    end: Dp = 0.dp
): ArcPaddingValues =
    ArcPaddingValuesImpl(outer, inner, start, end)

/**
 * Apply [all] dp of additional space along each edge of the content.
 */
public fun ArcPaddingValues(all: Dp): ArcPaddingValues = ArcPaddingValuesImpl(all, all, all, all)

/**
 * Apply [radial] dp of additional space on the edges towards and away from the center, and
 * [angular] dp before and after the component.
 */
public fun ArcPaddingValues(radial: Dp = 0.dp, angular: Dp = 0.dp): ArcPaddingValues =
    ArcPaddingValuesImpl(radial, radial, angular, angular)

@Stable
internal class ArcPaddingValuesImpl(val outer: Dp, val inner: Dp, val start: Dp, val end: Dp) :
    ArcPaddingValues {
    override fun equals(other: Any?): Boolean {
        return other is ArcPaddingValuesImpl &&
            outer == other.outer &&
            inner == other.inner &&
            start == other.start &&
            end == other.end
    }

    override fun hashCode() = ((outer.hashCode() * 31 + inner.hashCode()) * 31 + start.hashCode()) *
        31 + end.hashCode()

    override fun toString(): String {
        return "ArcPaddingValuesImpl(outer=$outer, inner=$inner, start=$start, end=$end)"
    }

    override fun calculateOuterPadding() = outer
    override fun calculateInnerPadding() = inner
    override fun calculateStartPadding() = start
    override fun calculateEndPadding() = end
}

/**
 * [basicCurvedText] is a component allowing developers to easily write curved text following
 * the curvature a circle (usually at the edge of a circular screen).
 * [basicCurvedText] can be only created within the [CurvedLayout] since it's not a not a
 * composable.
 *
 * @sample androidx.wear.compose.foundation.samples.CurvedAndNormalText
 *
 * @param text The text to display
 * @param modifier The [CurvedModifier] to apply to this curved text.
 * @param angularDirection Specify if the text is laid out clockwise or anti-clockwise, and if
 * those needs to be reversed in a Rtl layout.
 * If not specified, it will be inherited from the enclosing [curvedRow] or [CurvedLayout]
 * See [CurvedDirection.Angular].
 * @param contentArcPadding Allows to specify additional space along each "edge" of the content in
 * [Dp] see [ArcPaddingValues]
 * @param overflow How visual overflow should be handled.
 * @param style A @Composable factory to provide the style to use. This composable SHOULDN'T
 * generate any compose nodes.
 */
public fun CurvedScope.basicCurvedText(
    text: String,
    modifier: CurvedModifier = CurvedModifier,
    angularDirection: CurvedDirection.Angular? = null,
    contentArcPadding: ArcPaddingValues = ArcPaddingValues(0.dp),
    overflow: TextOverflow = TextOverflow.Clip,
    style: @Composable () -> CurvedTextStyle = { CurvedTextStyle() }
) = add(CurvedTextChild(
    text,
    curvedLayoutDirection.copy(overrideAngular = angularDirection).clockwise(),
    contentArcPadding,
    style,
    overflow
), modifier)

/**
 * [basicCurvedText] is a component allowing developers to easily write curved text following
 * the curvature a circle (usually at the edge of a circular screen).
 * [basicCurvedText] can be only created within the [CurvedLayout] since it's not a not a
 * composable.
 *
 * @sample androidx.wear.compose.foundation.samples.CurvedAndNormalText
 *
 * @param text The text to display
 * @param style A style to use.
 * @param modifier The [CurvedModifier] to apply to this curved text.
 * @param angularDirection Specify if the text is laid out clockwise or anti-clockwise, and if
 * those needs to be reversed in a Rtl layout.
 * If not specified, it will be inherited from the enclosing [curvedRow] or [CurvedLayout]
 * See [CurvedDirection.Angular].
 * @param contentArcPadding Allows to specify additional space along each "edge" of the content in
 * [Dp] see [ArcPaddingValues]
 * @param overflow How visual overflow should be handled.
 */
public fun CurvedScope.basicCurvedText(
    text: String,
    style: CurvedTextStyle,
    modifier: CurvedModifier = CurvedModifier,
    angularDirection: CurvedDirection.Angular? = null,
    // TODO: reimplement as modifiers
    contentArcPadding: ArcPaddingValues = ArcPaddingValues(0.dp),
    overflow: TextOverflow = TextOverflow.Clip,
) = basicCurvedText(text, modifier, angularDirection, contentArcPadding, overflow) { style }

internal class CurvedTextChild(
    val text: String,
    val clockwise: Boolean = true,
    val contentArcPadding: ArcPaddingValues = ArcPaddingValues(0.dp),
    val style: @Composable () -> CurvedTextStyle = { CurvedTextStyle() },
    val overflow: TextOverflow
) : CurvedChild() {
    private val delegate: CurvedTextDelegate = CurvedTextDelegate()
    private lateinit var actualStyle: CurvedTextStyle

    override fun MeasureScope.initializeMeasure(
        measurables: List<Measurable>,
        index: Int
    ): Int {
        // TODO: move padding into a CurvedModifier
        val arcPaddingPx = ArcPaddingPx(
            contentArcPadding.calculateOuterPadding().toPx(),
            contentArcPadding.calculateInnerPadding().toPx(),
            contentArcPadding.calculateStartPadding().toPx(),
            contentArcPadding.calculateEndPadding().toPx()
        )
        delegate.updateIfNeeded(text, clockwise, actualStyle.fontSize.toPx(), arcPaddingPx)
        return index // No measurables where mapped.
    }

    @Composable
    override fun SubComposition() {
        actualStyle = DefaultCurvedTextStyles + style()
    }

    override fun doEstimateThickness(maxRadius: Float): Float = delegate.textHeight

    override fun doRadialPosition(
        parentOuterRadius: Float,
        parentThickness: Float
    ): PartialLayoutInfo {
        val measureRadius = parentOuterRadius - delegate.baseLinePosition
        return PartialLayoutInfo(
            delegate.textWidth / measureRadius,
            parentOuterRadius,
            delegate.textHeight,
            measureRadius
        )
    }

    private var parentSweepRadians: Float = 0f

    override fun doAngularPosition(
        parentStartAngleRadians: Float,
        parentSweepRadians: Float,
        centerOffset: Offset
    ): Float {
        this.parentSweepRadians = parentSweepRadians
        return super.doAngularPosition(parentStartAngleRadians, parentSweepRadians, centerOffset)
    }

    override fun DrawScope.draw() {
        with(delegate) {
            doDraw(
                layoutInfo!!,
                parentSweepRadians,
                overflow,
                actualStyle.color,
                actualStyle.background
            )
        }
    }
}

internal data class ArcPaddingPx(
    val outer: Float,
    val inner: Float,
    val before: Float,
    val after: Float
)

internal expect class CurvedTextDelegate() {
    var textWidth: Float
    var textHeight: Float
    var baseLinePosition: Float

    fun updateIfNeeded(
        text: String,
        clockwise: Boolean,
        fontSizePx: Float,
        arcPaddingPx: ArcPaddingPx
    )

    fun DrawScope.doDraw(
        layoutInfo: CurvedLayoutInfo,
        parentSweepRadians: Float,
        overflow: TextOverflow,
        color: Color,
        background: Color
    )
}