Skip to content

DarkThemeColour.kt

src/jvmMain/kotlin/afield/ui/notation/DarkThemeColour.kt · 80 lines

package afield.ui.notation

import kotlin.math.roundToInt

Colour correction for marks written from code, so that they show as asked under the drawing layer's dark theme rather than as their inverted, hue-rotated selves.

object DarkThemeColour

Colours as the drawing layer shows them. Excalidraw's dark theme draws the scene through the CSS filter invert(93%) hue-rotate(180deg) (theme="dark" in tools/excalidraw-bundle/entry.jsx), so a colour stored on an element is not the colour on screen. Per channel the inversion is 0.93 - 0.86 c; the hue rotation is the CSS filter matrix H for 180°.

compensate gives the colour to store so that what is shown is as close as the filter allows to the colour code asked for: H (0.93 - 0.86 s) = t, solved and then kept inside the displayable range by projected least squares. Greys and many mid colours come out exact; warm and pale ones (#ffec99, #e03131) cannot be reached and come out duller or shifted. The default ink DEFAULT_INK is left as the theme draws it.

const val DEFAULT_INK = "#1e1e1e"

    private val H = arrayOf(
        doubleArrayOf(-0.574, 1.430, 0.144),
        doubleArrayOf(0.426, 0.430, 0.144),
        doubleArrayOf(0.426, 1.430, -0.856),
    )

    private fun shownOf(stored: DoubleArray): DoubleArray {
        val u = DoubleArray(3) { 0.93 - 0.86 * stored[it] }
        return DoubleArray(3) { r -> (H[r][0] * u[0] + H[r][1] * u[1] + H[r][2] * u[2]).coerceIn(0.0, 1.0) }
    }

fun shown(storedHex: String): String

What the dark theme shows for a stored #rrggbb.

    fun shown(storedHex: String): String = parse(storedHex)?.let { format(shownOf(it)) } ?: storedHex

fun compensate(requestedHex: String): String

The #rrggbb to store so the dark theme shows requestedHex; anything that is not #rrggbb passes through.

    fun compensate(requestedHex: String): String {
        // Excalidraw's default ink is theme-relative: it should stay light on dark, as hand-drawn marks are.
        if (requestedHex.trim().equals(DEFAULT_INK, ignoreCase = true)) return requestedHex
        val t = parse(requestedHex) ?: return requestedHex
        // Unconstrained solution, then projected gradient descent on ||H(0.93 - 0.86 s) - t||² over s in [0,1]³.
        val inv = invert(H)
        val u = DoubleArray(3) { r -> inv[r][0] * t[0] + inv[r][1] * t[1] + inv[r][2] * t[2] }
        val s = DoubleArray(3) { ((0.93 - u[it]) / 0.86).coerceIn(0.0, 1.0) }
        repeat(400) {
            val uNow = DoubleArray(3) { 0.93 - 0.86 * s[it] }
            val residual = DoubleArray(3) { r -> H[r][0] * uNow[0] + H[r][1] * uNow[1] + H[r][2] * uNow[2] - t[r] }
            for (c in 0..2) {
                val gradient = -0.86 * (H[0][c] * residual[0] + H[1][c] * residual[1] + H[2][c] * residual[2])
                s[c] = (s[c] - 0.15 * gradient).coerceIn(0.0, 1.0)
            }
        }
        return format(s)
    }

    private fun parse(hex: String): DoubleArray? {
        val h = hex.trim().removePrefix("#")
        if (h.length != 6 || h.any { it.lowercaseChar() !in "0123456789abcdef" }) return null
        return DoubleArray(3) { h.substring(2 * it, 2 * it + 2).toInt(16) / 255.0 }
    }

    private fun format(c: DoubleArray): String =
        "#" + c.joinToString("") { (it.coerceIn(0.0, 1.0) * 255).roundToInt().toString(16).padStart(2, '0') }

    private fun invert(m: Array<DoubleArray>): Array<DoubleArray> {
        val (a, b, c) = m[0].let { Triple(it[0], it[1], it[2]) }
        val (d, e, f) = m[1].let { Triple(it[0], it[1], it[2]) }
        val (g, h, i) = m[2].let { Triple(it[0], it[1], it[2]) }
        val det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
        return arrayOf(
            doubleArrayOf((e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det),
            doubleArrayOf((f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det),
            doubleArrayOf((d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det),
        )
    }
}