diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 9303dcb6..6ddf02b5 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -180,6 +180,10 @@ Pausa Salva Annulla + Scegli un colore + Esadecimale + Usa #RRGGBB o #AARRGGBB + Applica Espandi Comprimi Impostazioni: %1$s diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ba373128..a7c1d7dd 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -180,6 +180,10 @@ Pause Save Cancel + Choose a color + Hex + Use #RRGGBB or #AARRGGBB + Apply Expand Collapse Settings: %1$s @@ -474,4 +478,4 @@ Filter: Sort: Sync All - \ No newline at end of file + diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt index 48900e2c..4faf3523 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt @@ -21,12 +21,14 @@ import org.wip.plugintoolkit.features.colorpicker.ui.pickers.SimpleRingColorPick fun ColorPicker( modifier: Modifier = Modifier, type: ColorPickerType = ColorPickerType.Classic(), + initialColor: Color = Color.White, onPickedColor: (Color) -> Unit ) { Box(modifier = modifier) { when (type) { is ColorPickerType.Classic -> ClassicColorPicker( showAlphaBar = type.showAlphaBar, + initialColor = initialColor, onPickedColor = onPickedColor, ) @@ -62,4 +64,3 @@ fun ColorPicker( private fun ColorPickerPreview() { ColorPicker(onPickedColor = {}) } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt index 7d09ca4c..94bc7fda 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt @@ -9,12 +9,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,119 +27,103 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import org.jetbrains.compose.resources.stringResource +import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.colorpicker.model.ColorPickerType -import org.wip.plugintoolkit.features.colorpicker.utils.toCMYK -import org.wip.plugintoolkit.features.colorpicker.utils.toHSL +import org.wip.plugintoolkit.features.colorpicker.utils.parseHexColor import org.wip.plugintoolkit.features.colorpicker.utils.toHex -import org.wip.plugintoolkit.features.colorpicker.utils.toRGB import org.wip.plugintoolkit.features.colorpicker.utils.transparentBackground -import org.wip.plugintoolkit.shared.components.SelectedButtonGroup -import org.wip.plugintoolkit.core.theme.ToolkitTheme +import plugintoolkit.composeapp.generated.resources.Res +import plugintoolkit.composeapp.generated.resources.action_cancel +import plugintoolkit.composeapp.generated.resources.color_picker_apply +import plugintoolkit.composeapp.generated.resources.color_picker_hex +import plugintoolkit.composeapp.generated.resources.color_picker_hex_hint +import plugintoolkit.composeapp.generated.resources.color_picker_title -/** - * Color picker wrapped in a dialog. - * - * @param show Whether the dialog is visible. - * @param onDismissRequest Called when the user tries to dismiss the dialog. - * @param initialType The picker style — defaults to [ColorPickerType.Classic]. - * @param onPickedColor Callback invoked when the user confirms a color selection. - */ +/** A focused, editable color picker dialog with explicit cancel/apply actions. */ @Composable fun ColorPickerDialog( show: Boolean, onDismissRequest: () -> Unit, - initialType: ColorPickerType = ColorPickerType.Classic(), + initialColor: Color = Color.White, + showAlpha: Boolean = false, onPickedColor: (Color) -> Unit ) { - var showDialog by remember(show) { mutableStateOf(show) } - var color by remember { mutableStateOf(Color.White) } - var selectedFormat by remember { mutableStateOf("HEX") } - var type by remember { mutableStateOf(initialType) } + if (!show) return - if (showDialog) { - Dialog( - onDismissRequest = { - onDismissRequest() - showDialog = false - }) { - val includeAlpha = when (type) { - is ColorPickerType.Circle -> (type as ColorPickerType.Circle).showAlphaBar - is ColorPickerType.Classic -> (type as ColorPickerType.Classic).showAlphaBar - is ColorPickerType.Ring -> (type as ColorPickerType.Ring).showAlphaBar - else -> false - } + var color by remember(initialColor) { mutableStateOf(initialColor) } + var hexInput by remember(initialColor, showAlpha) { + mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase()) + } + val parsedHex = remember(hexInput) { parseHexColor(hexInput) } + + Dialog(onDismissRequest = onDismissRequest) { + Surface( + modifier = Modifier.widthIn(max = ToolkitTheme.dimensions.minWidthMedium), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = ToolkitTheme.dimensions.elevationHighMedium + ) { + Column( + modifier = Modifier.padding(ToolkitTheme.spacing.extraLarge), + verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) + ) { + Text( + text = stringResource(Res.string.color_picker_title), + style = MaterialTheme.typography.headlineSmall + ) - val colorCode = remember(color, selectedFormat) { - when (selectedFormat) { - "HEX" -> color.toHex(hexPrefix = true, includeAlpha = includeAlpha) - "RGB" -> color.toRGB(rgbPrefix = true, includeAlpha = includeAlpha) - "HSL" -> color.toHSL(hslPrefix = true, includeAlpha = includeAlpha) - "CMYK" -> color.toCMYK(cmykPrefix = true, includeAlpha = includeAlpha) - else -> color.toHex(hexPrefix = true, includeAlpha = includeAlpha) + ColorPicker( + type = ColorPickerType.Classic(showAlphaBar = showAlpha), + initialColor = initialColor, + onPickedColor = { + color = it + hexInput = it.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase() + } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) + ) { + Box( + modifier = Modifier + .size(ToolkitTheme.dimensions.heightMediumLarge) + .clip(RoundedCornerShape(ToolkitTheme.spacing.small)) + .transparentBackground(verticalBoxesAmount = 4) + .background(parsedHex ?: color) + ) + OutlinedTextField( + value = hexInput, + onValueChange = { input -> + hexInput = input.take(9) + parseHexColor(hexInput)?.let { color = it } + }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(Res.string.color_picker_hex)) }, + supportingText = if (parsedHex == null) { + { Text(stringResource(Res.string.color_picker_hex_hint)) } + } else null, + isError = parsedHex == null, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace) + ) } - } - Surface( - modifier = Modifier.widthIn(max = ToolkitTheme.dimensions.minWidthMedium), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = ToolkitTheme.dimensions.elevationHighMedium - ) { - Box(modifier = Modifier.padding(ToolkitTheme.spacing.extraLarge)) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismissRequest) { + Text(stringResource(Res.string.action_cancel)) + } + Button( + onClick = { parsedHex?.let(onPickedColor) }, + enabled = parsedHex != null ) { - SelectedButtonGroup( - buttons = listOf("HEX", "RGB", "HSL", "CMYK"), - startingIndex = 0, - onButtonSelected = { selectedFormat = it } - ) - SelectedButtonGroup( - buttons = listOf("Classic", "Circle", "Ring", "Simple"), - startingIndex = 0, - onButtonSelected = { - type = when (it) { - "Classic" -> ColorPickerType.Classic() - "Circle" -> ColorPickerType.Circle() - "Ring" -> ColorPickerType.Ring() - "Simple" -> ColorPickerType.SimpleRing() - else -> ColorPickerType.Classic() - } - } - ) - ColorPicker(type = type, onPickedColor = { color = it }) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) - ) { - Box( - modifier = Modifier - .size(ToolkitTheme.dimensions.containerWidthMediumLarge, ToolkitTheme.dimensions.heightMediumLarge) - .clip(RoundedCornerShape(50)) - .transparentBackground(verticalBoxesAmount = 4) - .background(color) - ) - Text( - text = colorCode, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 14.sp, - fontFamily = FontFamily.Monospace, - ) - } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - onPickedColor(color) - showDialog = false - }, - shape = CircleShape - ) { - Text(text = "Select") - } + Text(stringResource(Res.string.color_picker_apply)) } } } @@ -150,11 +135,6 @@ fun ColorPickerDialog( @Composable private fun ColorPickerDialogPreview() { MaterialTheme { - ColorPickerDialog( - show = true, - onDismissRequest = {}, - onPickedColor = {} - ) + ColorPickerDialog(show = true, onDismissRequest = {}, onPickedColor = {}) } } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt index 2739e6d4..ade2963b 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt @@ -35,6 +35,8 @@ import org.wip.plugintoolkit.features.colorpicker.utils.fromHueProgress import org.wip.plugintoolkit.features.colorpicker.utils.green import org.wip.plugintoolkit.features.colorpicker.utils.lighten import org.wip.plugintoolkit.features.colorpicker.utils.red +import org.wip.plugintoolkit.features.colorpicker.utils.saturationAndValue +import org.wip.plugintoolkit.features.colorpicker.utils.toHueProgress import kotlin.math.roundToInt import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -45,18 +47,33 @@ import org.wip.plugintoolkit.core.theme.ToolkitTheme internal fun ClassicColorPicker( modifier: Modifier = Modifier, showAlphaBar: Boolean, + initialColor: Color = Color.White, onPickedColor: (Color) -> Unit ) { - var pickerLocation by remember { mutableStateOf(Offset.Zero) } + val initialSaturationAndValue = remember(initialColor) { initialColor.saturationAndValue() } + val initialHue = remember(initialColor) { initialColor.toHueProgress() } + var pickerLocation by remember(initialColor) { mutableStateOf(Offset.Zero) } var colorPickerSize by remember { mutableStateOf(IntSize.Zero) } - var alpha by remember { mutableStateOf(1f) } - var rangeColor by remember { mutableStateOf(Color.White) } - var hueSlider by remember { mutableStateOf(0f) } + var pickerInitialized by remember(initialColor) { mutableStateOf(false) } + var alpha by remember(initialColor) { mutableStateOf(initialColor.alpha) } + var rangeColor by remember(initialColor) { mutableStateOf(Color.fromHueProgress(initialHue)) } + var hueSlider by remember(initialColor) { mutableStateOf(initialHue) } - var color by remember { mutableStateOf(Color.White) } + var color by remember(initialColor) { mutableStateOf(initialColor) } - LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha) { - if (colorPickerSize.width > 0 && colorPickerSize.height > 0) { + LaunchedEffect(colorPickerSize, initialColor) { + if (colorPickerSize.width > 0 && colorPickerSize.height > 0 && !pickerInitialized) { + val (saturation, value) = initialSaturationAndValue + pickerLocation = Offset( + x = saturation * colorPickerSize.width, + y = (1f - value) * colorPickerSize.height + ) + pickerInitialized = true + } + } + + LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha, pickerInitialized) { + if (pickerInitialized && colorPickerSize.width > 0 && colorPickerSize.height > 0) { val xProgress = if (colorPickerSize.width > 0) { (1 - (pickerLocation.x / colorPickerSize.width)).coerceIn(0f, 1f) } else 0f @@ -142,4 +159,3 @@ private fun ClassicColorPickerPreview() { ClassicColorPicker(showAlphaBar = true, onPickedColor = {}) } } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt index 16c6e6a6..5ed4a835 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt @@ -8,6 +8,37 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +/** Parses #RGBA, #RRGGBB or #AARRGGBB; long alpha values match the ARGB order emitted by [toHex]. */ +fun parseHexColor(value: String): Color? { + val digits = value.trim().removePrefix("#") + val normalized = when (digits.length) { + 4 -> { + val red = digits[0].toString().repeat(2) + val green = digits[1].toString().repeat(2) + val blue = digits[2].toString().repeat(2) + val alpha = digits[3].toString().repeat(2) + "$alpha$red$green$blue" + } + 6 -> "FF$digits" + 8 -> digits + else -> return null + } + val argb = normalized.toLongOrNull(16) ?: return null + return Color(argb.toInt()) +} + +/** Detects alpha-bearing legacy and current color representations without changing their format. */ +fun colorStringHasAlpha(value: String): Boolean { + val trimmed = value.trim() + if (trimmed.startsWith("rgba(", ignoreCase = true) || trimmed.startsWith("hsla(", ignoreCase = true)) { + return true + } + val candidate = value.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim().orEmpty() + val digits = candidate.removePrefix("#") + return (candidate.startsWith("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || + (digits.length == 8 && digits.toLongOrNull(16) != null) +} + /** * Returns an integer array for all color channels value. */ @@ -236,5 +267,15 @@ internal fun Color.toHueProgress(): Float { hue *= 60 if (hue < 0) hue += 360 - return hue + return hue / 360f +} + +internal fun Color.saturationAndValue(): Pair { + val red = red() / 255f + val green = green() / 255f + val blue = blue() / 255f + val maximum = max(red, max(green, blue)) + val minimum = min(red, min(green, blue)) + val saturation = if (maximum == 0f) 0f else (maximum - minimum) / maximum + return saturation to maximum } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt index e567dc53..20141f82 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt @@ -40,6 +40,7 @@ import org.wip.plugintoolkit.api.parseSemanticTypes import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.colorpicker.utils.toHex import org.wip.plugintoolkit.features.colorpicker.utils.toRGB +import org.wip.plugintoolkit.features.colorpicker.utils.colorStringHasAlpha import org.wip.plugintoolkit.features.flows.model.Node import org.wip.plugintoolkit.features.flows.model.PortConstraints import org.wip.plugintoolkit.shared.components.ToolkitTextField @@ -306,9 +307,13 @@ fun NodeDialogs( if (showColorPicker && activeColorInputId != null) { val input = node.inputs.firstOrNull { it.id == activeColorInputId } val inferredSem = input?.let { inferredSemanticTypes[Pair(node.id, it.id)] ?: it.semanticTypes } ?: emptyList() - val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } + val existingValue = input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" + val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } || + colorStringHasAlpha(existingValue) org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog( show = showColorPicker, + initialColor = parseColorString(existingValue), + showAlpha = hasAlpha, onDismissRequest = onDismissColorPicker, onPickedColor = { color -> activeColorInputId.let { inputId -> @@ -323,8 +328,6 @@ fun NodeDialogs( color.toHex(hexPrefix = true, includeAlpha = hasAlpha) } val isArray = input?.dataType is DataType.Array - val existingValue = - input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" val newValue = appendPickedValue(existingValue, formatted, isArray) onUpdateValue(node.id, inputId, newValue) } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt index 0c651456..f92d3648 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.json.booleanOrNull import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.features.flows.model.Node import org.wip.plugintoolkit.core.theme.ToolkitTheme +import org.wip.plugintoolkit.features.colorpicker.utils.parseHexColor @Composable fun PortCircle( @@ -154,9 +155,17 @@ fun getNodeDescription(node: Node): String { } fun parseColorString(colorStr: String): Color { - val lastColor = colorStr.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim() ?: colorStr + val completeValue = colorStr.trim() + val isFunctionalColor = completeValue.startsWith("rgb(", ignoreCase = true) || + completeValue.startsWith("rgba(", ignoreCase = true) + val lastColor = if (isFunctionalColor) { + completeValue + } else { + colorStr.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim() ?: colorStr + } val trimmed = lastColor.trim() if (trimmed.isEmpty()) return Color.Transparent + parseHexColor(trimmed)?.let { return it } if (trimmed.startsWith("#")) { return try { val hex = trimmed.substring(1) @@ -184,10 +193,10 @@ fun parseColorString(colorStr: String): Color { } 8 -> { - val r = hex.substring(0, 2).toInt(16) / 255f - val g = hex.substring(2, 4).toInt(16) / 255f - val b = hex.substring(4, 6).toInt(16) / 255f - val a = hex.substring(6, 8).toInt(16) / 255f + val a = hex.substring(0, 2).toInt(16) / 255f + val r = hex.substring(2, 4).toInt(16) / 255f + val g = hex.substring(4, 6).toInt(16) / 255f + val b = hex.substring(6, 8).toInt(16) / 255f Color(r, g, b, a) } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt index 0d55211a..75b37a8d 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp -import org.wip.plugintoolkit.features.colorpicker.model.ColorPickerType import org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog import org.wip.plugintoolkit.features.settings.model.AppSettings import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -28,7 +27,7 @@ fun AccentColorControl(settings: AppSettings, onUpdate: (AppSettings) -> Unit) { ColorPickerDialog( show = showColorPicker, - initialType = ColorPickerType.Classic(), + initialColor = Color(settings.appearance.accentColor), onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> onUpdate( diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt index 0b7da0b7..f355fa46 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt @@ -32,6 +32,8 @@ import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.ParameterMetadata import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog +import org.wip.plugintoolkit.features.colorpicker.utils.colorStringHasAlpha +import org.wip.plugintoolkit.features.colorpicker.utils.parseHexColor import org.wip.plugintoolkit.features.colorpicker.utils.toHex import org.wip.plugintoolkit.features.colorpicker.utils.toRGB import org.wip.plugintoolkit.shared.components.plugin.StandardTextField @@ -52,6 +54,9 @@ fun ColorInput( var showColorPicker by remember { mutableStateOf(false) } val parsedColor = remember(value) { parseColorString(value) } val isArray = metadata.type is DataType.Array + val isRgba = metadata.semanticTypes.any { it.canonicalId.contains("rgba", ignoreCase = true) } || + colorStringHasAlpha(value) + val isRgb = metadata.semanticTypes.any { it.canonicalId.contains("rgb", ignoreCase = true) } Column(modifier = Modifier .fillMaxWidth() @@ -101,17 +106,16 @@ fun ColorInput( if (showColorPicker && enabled) { ColorPickerDialog( show = showColorPicker, + initialColor = parsedColor, + showAlpha = isRgba, onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> showColorPicker = false - val formatted = if (metadata.semanticTypes.any { - it.canonicalId.contains("rgb", ignoreCase = true) - } - ) { - color.toRGB() + val formatted = if (isRgb) { + color.toRGB(rgbPrefix = true, includeAlpha = isRgba) } else { - color.toHex() + color.toHex(hexPrefix = true, includeAlpha = isRgba) } onValueChange(formatted) } @@ -125,6 +129,7 @@ fun parseColorString(colorStr: String): Color { if (trimmed.isEmpty()) return Color.Transparent try { + parseHexColor(trimmed)?.let { return it } if (trimmed.startsWith("#")) { val hex = trimmed.substring(1) when (hex.length) { diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt new file mode 100644 index 00000000..d852c962 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt @@ -0,0 +1,71 @@ +package org.wip.plugintoolkit.features.colorpicker.utils + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ColorExtTest { + @Test + fun `hex parser accepts rgb and argb values`() { + assertEquals(Color(0xFF336699.toInt()), parseHexColor("#336699")) + assertEquals(Color(0xFF336699.toInt()), parseHexColor("336699")) + assertEquals(Color(0x80336699.toInt()), parseHexColor("80336699")) + assertEquals(true, colorStringHasAlpha("#80336699")) + assertEquals(true, colorStringHasAlpha("80336699")) + assertEquals(Color(0xAAFF0000.toInt()), parseHexColor("#F00A")) + } + + @Test + fun `ARGB values round trip without losing alpha`() { + val original = "80336699" + val parsed = parseHexColor(original)!! + + assertEquals(original.lowercase(), parsed.toHex(includeAlpha = colorStringHasAlpha(original))) + } + + @Test + fun `functional alpha colors are detected before component splitting`() { + assertEquals(true, colorStringHasAlpha("rgba(10, 20, 30, 0.5)")) + assertEquals(true, colorStringHasAlpha("HSLA(120, 50%, 50%, 0.25)")) + assertEquals(false, colorStringHasAlpha("rgb(10, 20, 30)")) + } + + @Test + fun `hex parser rejects malformed values`() { + assertNull(parseHexColor("#12345")) + assertNull(parseHexColor("#GG3366")) + } + + @Test + fun `hue progress is normalized`() { + assertEquals(0f, Color.Red.toHueProgress(), absoluteTolerance = 0.0001f) + assertEquals(1f / 3f, Color.Green.toHueProgress(), absoluteTolerance = 0.0001f) + assertEquals(2f / 3f, Color.Blue.toHueProgress(), absoluteTolerance = 0.0001f) + } + + @Test + fun `picker coordinates reconstruct the initial color including alpha`() { + listOf( + Color(0xFFFF0000.toInt()), + Color(0xFF336699.toInt()), + Color(0xFF00FF00.toInt()), + Color(0xFFFFFFFF.toInt()), + Color(0x80336699.toInt()) + ).forEach { expected -> + assertEquals(expected.toArgb(), reconstructPickerColor(expected).toArgb(), "Failed for ${expected.toHex(true)}") + } + } + + private fun reconstructPickerColor(color: Color): Color { + val (saturation, value) = color.saturationAndValue() + val hueColor = Color.fromHueProgress(color.toHueProgress()) + return Color( + hueColor.red().lighten(1f - saturation).darken(1f - value), + hueColor.green().lighten(1f - saturation).darken(1f - value), + hueColor.blue().lighten(1f - saturation).darken(1f - value), + color.alpha() + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt new file mode 100644 index 00000000..3d9f9d95 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt @@ -0,0 +1,25 @@ +package org.wip.plugintoolkit.features.flows.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import kotlin.test.Test +import kotlin.test.assertEquals + +class NodeColorParsingTest { + @Test + fun `parses the ARGB order emitted by the color formatter`() { + assertEquals(Color(0x80336699.toInt()).toArgb(), parseColorString("#80336699").toArgb()) + assertEquals(Color(0x80336699.toInt()).toArgb(), parseColorString("80336699").toArgb()) + } + + @Test + fun `parses functional colors before treating commas as array separators`() { + assertEquals(Color.Red.toArgb(), parseColorString("rgb(255, 0, 0)").toArgb()) + assertEquals(Color(0x80FF0000.toInt()).toArgb(), parseColorString("rgba(255, 0, 0, 0.5)").toArgb()) + } + + @Test + fun `parses short RGBA consistently with alpha detection`() { + assertEquals(Color(0xAAFF0000.toInt()).toArgb(), parseColorString("#F00A").toArgb()) + } +}