FontVariation.kt

/*
 * Copyright 2022 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.compose.ui.text.font

import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.util.fastAny

/**
 * Set font variation settings.
 *
 * To learn more about the font variation settings, see the list supported by
 * [fonts.google.com](https://fonts.google.com/variablefonts#axis-definitions).
 */
@ExperimentalTextApi
object FontVariation {
    /**
     * A collection of settings to apply to a single font.
     *
     * Settings must be unique on [Setting.axisName]
     */
    @Immutable
    class Settings(vararg settings: Setting) {
        /**
         * All settings, unique by [FontVariation.Setting.axisName]
         */
        val settings: List<Setting>

        /**
         * True if density is required to resolve any of these settings
         *
         * If false, density will not affect the result of any [Setting.toVariationValue].
         */
        internal val needsDensity: Boolean

        init {
            this.settings = ArrayList(settings
                .groupBy { it.axisName }
                .flatMap { (key, value) ->
                    require(value.size == 1) {
                        "'$key' must be unique. Actual [ [${value.joinToString()}]"
                    }
                    value
                })

            needsDensity = this.settings.fastAny { it.needsDensity }
        }

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is Settings) return false

            if (settings != other.settings) return false

            return true
        }

        override fun hashCode(): Int {
            return settings.hashCode()
        }
    }

    /**
     * Represents a single point in a variation, such as 0.7 or 100
     */
    @Immutable
    sealed interface Setting {
        /**
         * Convert a value to a final value for use as a font variation setting.
         *
         * If [needsDensity] is false, density may be null
         *
         * @param density to resolve from Compose types to feature-specific ranges.
         */
        fun toVariationValue(density: Density?): Float

        /**
         * True if this setting requires density to resolve
         *
         * When false, may toVariationValue may be called with null or any Density
         */
        val needsDensity: Boolean

        /**
         * The font variation axis, such as 'wdth' or 'ital'
         */
        val axisName: String
    }

    @Immutable
    private class SettingFloat(
        override val axisName: String,
        val value: Float
    ) : Setting {
        override fun toVariationValue(density: Density?): Float = value
        override val needsDensity: Boolean = false

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is SettingFloat) return false

            if (axisName != other.axisName) return false
            if (value != other.value) return false

            return true
        }

        override fun hashCode(): Int {
            var result = axisName.hashCode()
            result = 31 * result + value.hashCode()
            return result
        }

        override fun toString(): String {
            return "FontVariation.Setting(axisName='$axisName', value=$value)"
        }
    }

    @Immutable
    private class SettingTextUnit(
        override val axisName: String,
        val value: TextUnit
    ) : Setting {
        override fun toVariationValue(density: Density?): Float {
            // we don't care about pixel density as 12sp is the same "visual" size on all devices
            // instead we only care about font scaling, which changes visual size
            requireNotNull(density) { "density must not be null" }
            return value.value * density.fontScale
        }

        override val needsDensity: Boolean = true

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is SettingTextUnit) return false

            if (axisName != other.axisName) return false
            if (value != other.value) return false

            return true
        }

        override fun hashCode(): Int {
            var result = axisName.hashCode()
            result = 31 * result + value.hashCode()
            return result
        }

        override fun toString(): String {
            return "FontVariation.Setting(axisName='$axisName', value=$value)"
        }
    }

    @Immutable
    private class SettingInt(
        override val axisName: String,
        val value: Int
    ) : Setting {
        override fun toVariationValue(density: Density?): Float = value.toFloat()
        override val needsDensity: Boolean = false

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is SettingInt) return false

            if (axisName != other.axisName) return false
            if (value != other.value) return false

            return true
        }

        override fun hashCode(): Int {
            var result = axisName.hashCode()
            result = 31 * result + value
            return result
        }

        override fun toString(): String {
            return "FontVariation.Setting(axisName='$axisName', value=$value)"
        }
    }

    /**
     * Create a font variation setting for any axis supported by a font.
     *
     * ```
     * val setting = FontVariation.Setting('wght', 400f);
     * ```
     *
     * You should typically not use this in app-code directly, instead define a method for each
     * setting supported by your app/font.
     *
     * If you had a setting `fzzt` that set a variation setting called fizzable between 1 and 11,
     * define a function like this:
     *
     * ```
     * fun FontVariation.fizzable(fiz: Int): FontVariation.Setting {
     *    require(fiz in 1..11) { "'fzzt' must be in 1..11" }
     *    return Setting("fzzt", fiz.toFloat())
     * ```
     *
     * @param name axis name, must be 4 characters
     * @param value value for axis, not validated and directly passed to font
     */
    fun Setting(name: String, value: Float): Setting {
        require(name.length == 4) {
            "Name must be exactly four characters. Actual: '$name'"
        }
        return SettingFloat(name, value)
    }

    /**
     * Italic or upright, equivalent to [FontStyle]
     *
     * 'ital', 0.0f is upright, and 1.0f is italic.
     *
     * A platform _may_ provide automatic setting of `ital` on font load. When supported, `ital` is
     * automatically applied based on [FontStyle] if platform and the loaded font support 'ital'.
     *
     * Automatic mapping is done via [Settings]\([FontWeight], [FontStyle]\)
     *
     * To override this behavior provide an explicit FontVariation.italic to a [Font] that supports
     * variation settings.
     *
     * @param value [0.0f, 1.0f]
     */
    fun italic(value: Float): Setting {
        require(value in 0.0f..1.0f) {
            "'ital' must be in 0.0f..1.0f. Actual: $value"
        }
        return SettingFloat("ital", value)
    }

    /**
     * Optical size is how "big" a font appears to the eye.
     *
     * It should be set by a ratio from a font size.
     *
     * Adapt the style to specific text sizes. At smaller sizes, letters typically become optimized
     * for more legibility. At larger sizes, optimized for headlines, with more extreme weights and
     * widths.
     *
     * A Platform _may_ choose to support automatic optical sizing. When present, this will set the
     * optical size based on the font size.
     *
     * To override this behavior provide an explicit FontVariation.opticalSizing to a [Font] that
     * supports variation settings.
     *
     * @param textSize font-size at the expected display, must be in sp
     */
    fun opticalSizing(textSize: TextUnit): Setting {
        require(textSize.isSp) {
            "'opsz' must be provided in sp units"
        }
        return SettingTextUnit("opsz", textSize)
    }

    /**
     * Adjust the style from upright to slanted, also known to typographers as an 'oblique' style.
     *
     * Rarely, slant can work in the other direction, called a 'backslanted' or 'reverse oblique'
     * style.
     *
     * 'slnt', values as an angle, 0f is upright.
     *
     * @param value -90f to 90f, represents an angle
     */
    fun slant(value: Float): Setting {
        require(value in -90f..90f) {
            "'slnt' must be in -90f..90f. Actual: $value"
        }
        return SettingFloat("slnt", value)
    }

    /**
     * Width of the type.
     *
     * Adjust the style from narrower to wider, by varying the proportions of counters, strokes,
     * spacing and kerning, and other aspects of the type. This typically changes the typographic
     * color in a subtle way, and so may be used in conjunction with Width and Grade axes.
     *
     * 'wdth', such as 10f
     *
     * @param value > 0.0f represents the width
     */
    fun width(value: Float): Setting {
        require(value > 0.0f) {
            "'wdth' must be strictly > 0.0f. Actual: $value"
        }
        return SettingFloat("wdth", value)
    }

    /**
     * Weight, equivalent to [FontWeight]
     *
     * Setting weight always causes visual text reflow, to make text "bolder" or "thinner" without
     * reflow see [grade]
     *
     * Adjust the style from lighter to bolder in typographic color, by varying stroke weights,
     * spacing and kerning, and other aspects of the type. This typically changes overall width,
     * and so may be used in conjunction with Width and Grade axes.
     *
     * This is equivalent to [FontWeight], and platforms _may_ support automatically setting 'wghts'
     * from [FontWeight] during font load.
     *
     * Setting this does not change [FontWeight]. If an explicit value and [FontWeight] disagree,
     * the weight specified by `wght` will be shown if the font supports it.
     *
     * Automatic mapping is done via [Settings]\([FontWeight], [FontStyle]\)
     *
     * @param value weight, in 1..1000
     */
    fun weight(value: Int): Setting {
        require(value in 1..1000) {
            "'wght' value must be in [1, 1000]. Actual: $value"
        }
        return SettingInt("wght", value)
    }

    /**
     * Change visual weight of text without text reflow.
     *
     * Finesse the style from lighter to bolder in typographic color, without any changes overall
     * width, line breaks or page layout. Negative grade makes the style lighter, while positive
     * grade makes it bolder. The units are the same as in the Weight axis.
     *
     * Visual appearance of text with weight and grade set is similar to text with
     *
     * ```
     * weight = (weight + grade)
     * ```
     *
     * @param value grade, in -1000..1000
     */
    fun grade(value: Int): Setting {
        require(value in -1000..1000) {
            "'GRAD' must be in -1000..1000"
        }
        return SettingInt("GRAD", value)
    }

    /**
     * Variation settings to configure a font with [FontWeight] and [FontStyle]
     *
     * @param weight to set 'wght' with [weight]\([FontWeight.weight])
     * @param style to set 'ital' with [italic]\([FontStyle.value])
     * @param settings other settings to apply, must not contain 'wght' or 'ital'
     * @return settings that configure [FontWeight] and [FontStyle] on a font that supports
     * 'wght' and 'ital'
     */
    fun Settings(
        weight: FontWeight,
        style: FontStyle,
        vararg settings: Setting
    ): Settings {
        return Settings(weight(weight.weight), italic(style.value.toFloat()), *settings)
    }
}