compose-rust 0.0.1

Declarative UI in Rust, rendered by an AOT-compiled Compose Multiplatform engine.
Documentation
package dev.darkpyonix.composerust.design

// A copy of Contrast.kt from the liquid-glass module of the design-systems
// project, with the package changed and SurfaceMaterial taken from this module rather
// than from that project's core. That project is published on its own and must never
// depend on the renderer, so the material exists twice deliberately. Copies run in one
// direction: change the design systems file first, then bring the change here.

import androidx.compose.ui.graphics.Color
import kotlin.math.pow

/**
 * Colour arithmetic the glass material needs in order to promise that its opaque
 * fallback is readable.
 *
 * A translucent surface has no fixed contrast: it depends on whatever happens to be
 * behind it that frame. The fallback does not, so it is the one place where a contrast
 * guarantee can actually be made, and these functions are how it is made rather than
 * asserted.
 *
 * The luminance and ratio formulas are the ones in WCAG 2.2: relative luminance from
 * linearised sRGB channels, ratio as (lighter + 0.05) / (darker + 0.05).
 */

/** WCAG relative luminance, 0 for black and 1 for white. Alpha is ignored. */
fun relativeLuminance(color: Color): Float {
    fun channel(value: Float): Float =
        if (value <= 0.03928f) value / 12.92f
        else ((value + 0.055f) / 1.055f).pow(2.4f)

    return 0.2126f * channel(color.red) +
        0.7152f * channel(color.green) +
        0.0722f * channel(color.blue)
}

/**
 * The WCAG contrast ratio between two opaque colours, from 1.0 (identical) to 21.0
 * (black against white).
 *
 * Both colours must be opaque. A translucent colour has no ratio of its own, because the
 * thing it is drawn over decides the answer; composite it first with [compositeOver].
 */
fun contrastRatio(a: Color, b: Color): Float {
    val la = relativeLuminance(a)
    val lb = relativeLuminance(b)
    val lighter = maxOf(la, lb)
    val darker = minOf(la, lb)
    return (lighter + 0.05f) / (darker + 0.05f)
}

/** [top] painted over opaque [bottom], using [top]'s alpha. The result is opaque. */
fun compositeOver(top: Color, bottom: Color): Color {
    val a = top.alpha
    return Color(
        red = top.red * a + bottom.red * (1f - a),
        green = top.green * a + bottom.green * (1f - a),
        blue = top.blue * a + bottom.blue * (1f - a),
        alpha = 1f,
    )
}

/**
 * [color] moved away from [against] until they are at least [minRatio] apart, and
 * returned unchanged if they already are.
 *
 * The move is a straight walk towards black or towards white, whichever direction
 * increases the ratio, so the hue survives and only the lightness gives way. The walk is
 * bounded: if even pure black or pure white does not reach [minRatio] then the darker or
 * lighter extreme is returned, which is the best that colour can do.
 *
 * This is what lets the glass fallback carry a contrast guarantee instead of a hope. A
 * design system hands over the colour it wants and the content colour that has to be
 * legible on it, and gets back something that is.
 */
fun ensureContrast(color: Color, against: Color, minRatio: Float): Color {
    // Opaque on every path, including the one where nothing had to move. A caller stores
    // this as the fill to use when the translucent one is unavailable, and a translucent
    // fallback would let whatever is behind it decide the ratio that was just guaranteed.
    // The ratio itself is unaffected: it is computed from the colour channels alone.
    if (contrastRatio(color, against) >= minRatio) return color.copy(alpha = 1f)

    // Both directions are walked rather than one being deduced from which colour is
    // lighter. That deduction is right only while the two are clearly apart. A surface
    // the colour of its own text has no away direction yet, and the comparison picks
    // whichever way the tie happens to fall; a surface one shade lighter than very light
    // text has an away direction that runs out of room long before the ratio is met.
    // Both cases used to return a colour that did not keep the promise this function
    // exists to make, silently. Two searches instead of one is the whole cost.
    val darker = nearestMeeting(color, Color.Black, against, minRatio)
    val lighter = nearestMeeting(color, Color.White, against, minRatio)
    val meeting = listOf(darker, lighter).filter { contrastRatio(it, against) >= minRatio }
    // Of the directions that work, the one that moved the colour least. Where neither
    // works the content colour is a mid grey and nothing can reach the ratio, so the
    // caller gets the best reading there is.
    return meeting.minByOrNull { squaredDistance(color, it) }
        ?: listOf(darker, lighter).maxBy { contrastRatio(it, against) }
}

/**
 * The point closest to [from] on the way to [target] that reads at [minRatio] against
 * [against], or [target] itself when no point on that line does.
 *
 * Binary search rather than a step walk, so the result is as close to the colour that was
 * asked for as the requirement allows.
 */
private fun nearestMeeting(from: Color, target: Color, against: Color, minRatio: Float): Color {
    var low = 0f
    var high = 1f
    var best = lerpOpaque(from, target, 1f)
    repeat(24) {
        val mid = (low + high) / 2f
        val candidate = lerpOpaque(from, target, mid)
        if (contrastRatio(candidate, against) >= minRatio) {
            best = candidate
            high = mid
        } else {
            low = mid
        }
    }
    return best
}

/** How far apart two colours are, for choosing the smaller of two corrections. */
private fun squaredDistance(a: Color, b: Color): Float {
    val red = a.red - b.red
    val green = a.green - b.green
    val blue = a.blue - b.blue
    return red * red + green * green + blue * blue
}

private fun lerpOpaque(from: Color, to: Color, t: Float): Color = Color(
    red = from.red + (to.red - from.red) * t,
    green = from.green + (to.green - from.green) * t,
    blue = from.blue + (to.blue - from.blue) * t,
    alpha = 1f,
)