Utilize HSL to create disabled component via chat color.

This commit is contained in:
Alex Hart
2026-08-26 15:22:20 -04:00
committed by Michelle Tang
parent 034e50fedf
commit ce5971237f
3 changed files with 167 additions and 6 deletions
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
/**
* This color's lightness in HSL space, where 0 is black and 1 is white.
*/
val Color.hslLightness: Float
get() {
val max = maxOf(red, green, blue)
val min = minOf(red, green, blue)
return (max + min) / 2f
}
/**
* Multiplies this color's HSL saturation by [fraction], leaving hue, lightness, and alpha untouched. A [fraction] below
* 1 washes the color out towards gray without changing how light or dark it reads.
*
* Unlike interpolating towards a gray in RGB space, this shifts every input by the same proportion, so a color that
* already starts out pale is muted just as visibly as a vivid one.
*/
fun Color.scaleSaturation(fraction: Float): Color {
val hsl = toHsl()
return Color.hsl(
hue = hsl[0],
saturation = (hsl[1] * fraction).coerceIn(0f, 1f),
lightness = hsl[2],
alpha = alpha
)
}
/**
* Moves this color [fraction] of the way to [other]'s lightness, leaving hue, saturation, and alpha untouched. The
* result keeps its own hue, so it stays recognizable while being pulled towards how light or dark [other] is.
*/
fun Color.shiftLightnessToward(other: Color, fraction: Float): Color {
val hsl = toHsl()
val target = other.hslLightness
return Color.hsl(
hue = hsl[0],
saturation = hsl[1],
lightness = (hsl[2] + (target - hsl[2]) * fraction).coerceIn(0f, 1f),
alpha = alpha
)
}
/**
* This color as `[hue, saturation, lightness]`, matching the ranges [Color.hsl] expects. Alpha is not included.
*/
private fun Color.toHsl(): FloatArray {
val max = maxOf(red, green, blue)
val min = minOf(red, green, blue)
val delta = max - min
val lightness = (max + min) / 2f
if (delta == 0f) {
return floatArrayOf(0f, 0f, lightness)
}
val hue = when (max) {
red -> 60f * (((green - blue) / delta) % 6f)
green -> 60f * (((blue - red) / delta) + 2f)
else -> 60f * (((red - green) / delta) + 4f)
}
return floatArrayOf(
if (hue < 0f) hue + 360f else hue,
delta / (1f - abs(2f * lightness - 1f)),
lightness
)
}
@@ -0,0 +1,53 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Derives themed color states for controls that are painted in a color the color scheme knows nothing about, such as a
* recipient's chat color. Colors from the scheme itself already have `on-` and container roles to pair with; these fill
* the same gap for custom ones.
*/
object ColorProfiles {
/** How much of a container's saturation survives into its disabled state. */
private const val DISABLED_SATURATION = 0.3f
/** How far a disabled container's lightness is pulled towards the surrounding chrome. */
private const val DISABLED_LIGHTNESS_SHIFT = 0.6f
/** Kept low so disabled content reads as inert against a container that has itself receded towards the chrome. */
private const val DISABLED_CONTENT_ALPHA = 0.6f
/**
* A muted version of [color] for a control that is currently disabled, washed out and pulled towards the surrounding
* chrome while keeping enough of its hue to stay recognizable.
*
* The result is opaque rather than translucent so it reads the same whatever it happens to sit on top of, which
* matters for controls that float over content such as media or a wallpaper.
*
* Pair with [disabledContent].
*/
@Composable
fun disabledContainer(color: Color): Color {
return color
.scaleSaturation(DISABLED_SATURATION)
.shiftLightnessToward(MaterialTheme.colorScheme.surfaceVariant, DISABLED_LIGHTNESS_SHIFT)
}
/**
* The content color to draw on top of [disabledContainer]. Follows the chrome rather than the container's own hue,
* because a container that has been pulled this far towards the chrome no longer reliably contrasts with whatever
* the enabled content color was.
*/
@Composable
fun disabledContent(): Color {
return MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = DISABLED_CONTENT_ALPHA)
}
}