From ce5971237f3249fffe5e5195534a9ebfd2f08f83 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 26 Aug 2026 13:15:38 -0300 Subject: [PATCH] Utilize HSL to create disabled component via chat color. --- .../signal/core/ui/compose/ColorExtensions.kt | 80 +++++++++++++++++++ .../signal/core/ui/compose/ColorProfiles.kt | 53 ++++++++++++ .../mediasend/screens/edit/AddAMessageRow.kt | 40 ++++++++-- 3 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/ColorExtensions.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/ColorProfiles.kt diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ColorExtensions.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ColorExtensions.kt new file mode 100644 index 0000000000..e5d9df75d2 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/ColorExtensions.kt @@ -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 + ) +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ColorProfiles.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ColorProfiles.kt new file mode 100644 index 0000000000..eb1cfcda75 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/ColorProfiles.kt @@ -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) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/AddAMessageRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/AddAMessageRow.kt index c16d028a96..4f0febda26 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/AddAMessageRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/AddAMessageRow.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.ColorProfiles import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.DropdownMenus import org.signal.core.ui.compose.IconButtons @@ -37,9 +38,6 @@ import org.signal.core.ui.compose.theme.SignalTheme import org.signal.mediasend.R import org.signal.mediasend.test.TestTags -/** Mirrors the legacy send button's disabled tint, which has no equivalent in the core-ui color scheme. */ -private val DisabledNextButtonColor = Color(0xFF777777) - /** * Mirrors the legacy send button's size. Has to be stated rather than left to the [IconButtons.IconButton] default of * 40dp: the default draws the container inside the 48dp of layout that [androidx.compose.material3.minimumInteractiveComponentSize] @@ -146,6 +144,8 @@ fun AddAMessageRow( Box { val scheduleSendMenuController = remember { DropdownMenus.MenuController() } + val nextButtonColor = recipientChatColor ?: MaterialTheme.colorScheme.primaryContainer + IconButtons.IconButton( enabled = enabled, onClick = onNextClick, @@ -153,10 +153,10 @@ fun AddAMessageRow( onLongClick = if (canScheduleSend) scheduleSendMenuController::show else null, onLongClickLabel = stringResource(R.string.AddAMessageRow__schedule_send), colors = IconButtons.iconButtonColors( - containerColor = recipientChatColor ?: MaterialTheme.colorScheme.primaryContainer, + containerColor = nextButtonColor, contentColor = if (recipientChatColor != null) SignalTheme.colors.colorOnCustom else MaterialTheme.colorScheme.onPrimaryContainer, - disabledContainerColor = DisabledNextButtonColor, - disabledContentColor = MaterialTheme.colorScheme.secondaryContainer + disabledContainerColor = ColorProfiles.disabledContainer(nextButtonColor), + disabledContentColor = ColorProfiles.disabledContent() ), modifier = Modifier .testTag(TestTags.ADD_A_MESSAGE_NEXT_BUTTON) @@ -244,6 +244,19 @@ private fun AddAMessageRowKnownRecipientPreview() { @DayNightPreviews @Composable private fun AddAMessageRowDisabledPreview() { + Previews.Preview { + AddAMessageRow( + message = null, + onEvent = {}, + onNextClick = {}, + enabled = false + ) + } +} + +@DayNightPreviews +@Composable +private fun AddAMessageRowDisabledKnownRecipientPreview() { Previews.Preview { AddAMessageRow( message = null, @@ -255,6 +268,21 @@ private fun AddAMessageRowDisabledPreview() { } } +/** A chat color that starts out pale, where a plain interpolation towards the chrome would barely move. */ +@DayNightPreviews +@Composable +private fun AddAMessageRowDisabledPaleRecipientPreview() { + Previews.Preview { + AddAMessageRow( + message = null, + onEvent = {}, + onNextClick = {}, + enabled = false, + recipientChatColor = Color(0xFFF5E0A0) + ) + } +} + @DayNightPreviews @Composable private fun AddAMessageRowLongContentPreview() {