diff --git a/composeApp/src/commonMain/kotlin/des/c5inco/mesh/common/MeshGradient.kt b/composeApp/src/commonMain/kotlin/des/c5inco/mesh/common/MeshGradient.kt index 0116642..a3d3689 100644 --- a/composeApp/src/commonMain/kotlin/des/c5inco/mesh/common/MeshGradient.kt +++ b/composeApp/src/commonMain/kotlin/des/c5inco/mesh/common/MeshGradient.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.graphics.lerp @Composable fun Modifier.meshGradient( points: List>>, - blendMode: BlendMode = BlendMode.DstIn, + gradientBlendMode: BlendMode = BlendMode.DstIn, resolutionX: Int = 1, resolutionY: Int = 1, showPoints: Boolean = false, @@ -36,6 +36,15 @@ fun Modifier.meshGradient( } } + val pointsPaint = remember { + Paint().apply { + color = Color.White.copy(alpha = .9f) + strokeWidth = 3f + strokeCap = StrokeCap.Round + blendMode = BlendMode.SrcOver + } + } + return drawWithCache { onDrawBehind { drawIntoCanvas { canvas -> @@ -52,29 +61,20 @@ fun Modifier.meshGradient( colors = pointData.colors, indices = indicesModifier(pointData.indices) ), - blendMode = blendMode, + blendMode = gradientBlendMode, paint = paint, ) } if (showPoints) { - val flattenedPaint = Paint() - flattenedPaint.color = Color.White.copy(alpha = .9f) - flattenedPaint.strokeWidth = 4f * .001f - flattenedPaint.strokeCap = StrokeCap.Round - flattenedPaint.blendMode = BlendMode.SrcOver - - scale( - scaleX = size.width, - scaleY = size.height, - pivot = Offset.Zero - ) { - canvas.drawPoints( - pointMode = PointMode.Points, - points = pointData.offsets, - paint = flattenedPaint - ) - } + val intermediatePoints = pointData.offsets + .map { Offset(it.x * size.width, it.y * size.height) } + + canvas.drawPoints( + pointMode = PointMode.Points, + points = intermediatePoints, + paint = pointsPaint + ) } } } @@ -285,4 +285,4 @@ private fun cubicPathY(point1: Offset, point2: Offset, position: Int): Path { return path } -private val paint = Paint() \ No newline at end of file +private val paint = Paint() diff --git a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/App.kt b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/App.kt index 36b510b..975372d 100644 --- a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/App.kt +++ b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/App.kt @@ -31,12 +31,11 @@ fun App() { val coroutineScope = rememberCoroutineScope() GradientCanvas( - exportGraphicsLayer = exportGraphicsLayer, - exportScale = exportScale, - onPointDrag = { selectedColorPoint = it }, - modifier = Modifier.weight(1f) - ) - SidePanel( + exportGraphicsLayer = exportGraphicsLayer, + exportScale = exportScale, + modifier = Modifier.weight(1f) + ) { selectedColorPoint = it } + SidePanel( exportScale = exportScale, onExportScaleChange = { exportScale = it }, onExport = { @@ -51,4 +50,4 @@ fun App() { modifier = Modifier.width(280.dp) ) } -} \ No newline at end of file +} diff --git a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/GradientCanvas.kt b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/GradientCanvas.kt index 6984765..b4c33a9 100644 --- a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/GradientCanvas.kt +++ b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/GradientCanvas.kt @@ -3,9 +3,19 @@ package des.c5inco.mesh.ui import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,24 +49,19 @@ import org.jetbrains.jewel.ui.util.thenIf fun GradientCanvas( exportGraphicsLayer: GraphicsLayer, exportScale: Int, + modifier: Modifier = Modifier, onPointDrag: (Pair?) -> Unit = { _ -> }, - modifier: Modifier = Modifier ) { val showPoints by remember { AppState::showPoints } val resolution by remember { AppState::resolution } val colors = remember { AppState.colorPoints } - val canvasWidthMode by AppState.canvasWidthMode.collectAsState() - val canvasHeightMode by AppState.canvasHeightMode.collectAsState() + val canvasSizeMode = AppState.canvasSizeMode var canvasWidth by remember { AppState::canvasWidth } var canvasHeight by remember { AppState::canvasHeight } val notifications = remember { mutableStateListOf() } - val exportSize by derivedStateOf { - mutableStateOf(IntSize(canvasWidth, canvasHeight)) - } - val density = LocalDensity.current LaunchedEffect(Unit) { @@ -68,7 +73,7 @@ fun GradientCanvas( } fun handlePositioned(coordinates: LayoutCoordinates) { - with (density) { + with(density) { val dpWidth = coordinates.size.width.toDp() val dpHeight = coordinates.size.height.toDp() @@ -80,13 +85,17 @@ fun GradientCanvas( Box( contentAlignment = Alignment.Center, modifier = modifier - .background(if (AppState.canvasBackgroundColor > -1) { - AppState.getColor(AppState.canvasBackgroundColor) - } else { - JewelTheme.colorPalette.gray(1) - }) + .background( + if (AppState.canvasBackgroundColor > -1) { + AppState.getColor(AppState.canvasBackgroundColor) + } else { + JewelTheme.colorPalette.gray(1) + } + ) .fillMaxSize() ) { + val canvasAspectRatio by remember { derivedStateOf { canvasWidth.toFloat() / canvasHeight } } + BoxWithConstraints( modifier = Modifier .pointerInput(Unit) { @@ -97,20 +106,12 @@ fun GradientCanvas( ) } .padding(32.dp) - .then( - if (canvasWidthMode == DimensionMode.Fill) { - Modifier.fillMaxWidth() - } else { - Modifier.width(canvasWidth.dp) - } - ) - .then( - if (canvasHeightMode == DimensionMode.Fill) { - Modifier.fillMaxHeight() - } else { - Modifier.height(canvasHeight.dp) - } - ) + .thenIf(canvasSizeMode == DimensionMode.Fixed) { + aspectRatio(canvasAspectRatio) + } + .thenIf(canvasSizeMode == DimensionMode.Fill) { + fillMaxSize() + } ) { val maxWidth = constraints.maxWidth val maxHeight = constraints.maxHeight @@ -134,18 +135,16 @@ fun GradientCanvas( Box( Modifier - .thenIf(canvasWidthMode == DimensionMode.Fill || canvasHeightMode == DimensionMode.Fill) { - Modifier.onGloballyPositioned { handlePositioned(it) } + .thenIf(canvasSizeMode == DimensionMode.Fill) { + onGloballyPositioned { handlePositioned(it) } } .clip(RoundedCornerShape(16.dp)) .drawWithContent { - // Record content on visible graphics layer + // Record content on a visible graphics layer graphicsLayer.record { this@drawWithContent.drawContent() } - val (width, height) = exportSize.value - // Scale and translate the export graphics layer accordingly exportGraphicsLayer.apply { scaleX = exportScale.toFloat() @@ -153,13 +152,15 @@ fun GradientCanvas( when (exportScale) { 3 -> { - translationX = width.toFloat() * 3 - translationY = height.toFloat() * 3 + translationX = canvasWidth.toFloat() * 3 + translationY = canvasHeight.toFloat() * 3 } + 2 -> { - translationX = width.toFloat() - translationY = height.toFloat() + translationX = canvasWidth.toFloat() + translationY = canvasHeight.toFloat() } + else -> { translationX = 0f translationY = 0f @@ -169,7 +170,7 @@ fun GradientCanvas( // Record content on the export graphics layer exportGraphicsLayer.record( - size = IntSize(width * exportScale, height * exportScale), + size = IntSize(canvasWidth * exportScale, canvasHeight * exportScale), ) { scale( scale = 1f / density.density, @@ -272,4 +273,4 @@ fun GradientCanvas( } } } -} \ No newline at end of file +} diff --git a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/SidePanel.kt b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/SidePanel.kt index 8ea635c..af1d15d 100644 --- a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/SidePanel.kt +++ b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/SidePanel.kt @@ -3,10 +3,29 @@ package des.c5inco.mesh.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -30,13 +49,28 @@ import des.c5inco.mesh.ui.components.OffsetInputField import des.c5inco.mesh.ui.data.AppState import des.c5inco.mesh.ui.data.DimensionMode import kotlinx.coroutines.flow.collectLatest -import mesh.composeapp.generated.resources.* +import mesh.composeapp.generated.resources.Res +import mesh.composeapp.generated.resources.distributeEvenly_dark +import mesh.composeapp.generated.resources.featureCodeBlock_dark +import mesh.composeapp.generated.resources.modeFilled_dark +import mesh.composeapp.generated.resources.modeFixed_dark import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.Outline -import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.CheckboxRow +import org.jetbrains.jewel.ui.component.Divider +import org.jetbrains.jewel.ui.component.DropdownLink +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Link +import org.jetbrains.jewel.ui.component.SelectableIconButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.component.Tooltip +import org.jetbrains.jewel.ui.component.Typography +import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.jewel.ui.theme.colorPalette import java.awt.Toolkit @@ -52,7 +86,7 @@ fun SidePanel( modifier: Modifier = Modifier ) { val focusManager = LocalFocusManager.current - + VerticallyScrollableContainer( modifier = modifier .fillMaxHeight() @@ -168,21 +202,21 @@ fun SidePanel( Row { DimensionInputField( value = AppState.colorPointsRows, - enabled = true, - paramName = "Rows", min = 2, max = 10, - onUpdate = { AppState.updatePointsRows(it.coerceIn(2, 10)) }, + enabled = true, + paramName = "Rows", + onValueChange = { AppState.updatePointsRows(it.coerceIn(2, 10)) }, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) DimensionInputField( value = AppState.colorPointsCols, - enabled = true, - paramName = "Cols", min = 2, max = 10, - onUpdate = { AppState.updatePointsCols(it.coerceIn(2, 10)) }, + enabled = true, + paramName = "Cols", + onValueChange = { AppState.updatePointsCols(it.coerceIn(2, 10)) }, modifier = Modifier.weight(1f) ) } @@ -355,10 +389,12 @@ private fun ColorInput( isColorValid = validate() if (isColorValid) onSubmit(color) } + Key.Tab -> { isColorValid = validate() return@onKeyEvent false } + Key.Escape -> { onCancel() } @@ -399,9 +435,8 @@ private fun ColorPointRow( ) { ColorDropdown( selectedColor = colorInt, - colors = AppState.colors, - onSelected = { onUpdatePoint(Pair(Offset(x = x, y = y), it)) } - ) + colors = AppState.colors + ) { onUpdatePoint(Pair(Offset(x = x, y = y), it)) } OffsetInputField( value = x, enabled = !constrainX, @@ -427,8 +462,7 @@ private fun CanvasSection( onExport: () -> Unit = {}, modifier: Modifier = Modifier ) { - val canvasWidthMode by AppState.canvasWidthMode.collectAsState() - val canvasHeightMode by AppState.canvasHeightMode.collectAsState() + val canvasSizeMode = AppState.canvasSizeMode val canvasWidth by remember { AppState::canvasWidth } val canvasHeight by remember { AppState::canvasHeight } val focusManager = LocalFocusManager.current @@ -444,60 +478,73 @@ private fun CanvasSection( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { + Text("Background color:") + ColorDropdown( selectedColor = AppState.canvasBackgroundColor, colors = AppState.colors, - allowTransparency = true, - onSelected = { AppState.canvasBackgroundColor = it } - ) + allowTransparency = true + ) { AppState.canvasBackgroundColor = it } + } + + Spacer(Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text("Size:") + + Tooltip(tooltip = { + Text(text = "Fill available space") + }) { + SelectableIconButton( + selected = AppState.canvasSizeMode == DimensionMode.Fill, + onClick = { + AppState.canvasSizeMode = DimensionMode.Fill + focusManager.clearFocus() + }, + ) { + Icon( + painter = painterResource(Res.drawable.modeFilled_dark), + contentDescription = "Fill available space" + ) + } + } + + Tooltip(tooltip = { + Text(text = "Fixed size") + }) { + SelectableIconButton( + selected = AppState.canvasSizeMode == DimensionMode.Fixed, + onClick = { + AppState.canvasSizeMode = DimensionMode.Fixed + focusManager.clearFocus() + }, + ) { + Icon( + painter = painterResource(Res.drawable.modeFixed_dark), + contentDescription = "Fixed size" + ) + } + } + DimensionInputField( value = canvasWidth, - enabled = canvasWidthMode == DimensionMode.Fixed, - paramName = "W", min = 100, - trailingIcon = { - Tooltip(tooltip = { - Text(text = "Toggle to ${if (canvasWidthMode == DimensionMode.Fixed) "Filled" else "Fixed"}") - }) { - IconButton( - onClick = { - AppState.updateCanvasWidthMode() - focusManager.clearFocus() - } - ) { - Icon( - painter = painterResource(getModeIcon(canvasWidthMode)), - contentDescription = null - ) - } - } - }, - onUpdate = { AppState.canvasWidth = it }, + enabled = canvasSizeMode == DimensionMode.Fixed, + paramName = "W", + onValueChange = { AppState.canvasWidth = it }, modifier = Modifier.weight(1f) ) + DimensionInputField( value = canvasHeight, - enabled = canvasHeightMode == DimensionMode.Fixed, - paramName = "H", min = 100, - trailingIcon = { - Tooltip(tooltip = { - Text(text = "Toggle to ${if (canvasHeightMode == DimensionMode.Fixed) "Filled" else "Fixed"}") - }) { - IconButton( - onClick = { - AppState.updateCanvasHeightMode() - focusManager.clearFocus() - } - ) { - Icon( - painter = painterResource(getModeIcon(canvasHeightMode)), - contentDescription = null - ) - } - } - }, - onUpdate = { AppState.canvasHeight = it }, + enabled = canvasSizeMode == DimensionMode.Fixed, + paramName = "H", + onValueChange = { AppState.canvasHeight = it }, modifier = Modifier.weight(1f) ) } @@ -525,7 +572,14 @@ private fun CanvasSection( onExportScaleChange(it + 1) }, ) { - Text("${it + 1}x") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val scaleFactor = it + 1 + Text("${scaleFactor}x") + Text( + "(${canvasWidth * scaleFactor}x${canvasHeight * scaleFactor} px)", + color = JewelTheme.globalColors.text.info + ) + } } } } @@ -539,4 +593,4 @@ private fun getModeIcon(mode: DimensionMode): DrawableResource { } else { Res.drawable.modeFilled_dark } -} \ No newline at end of file +} diff --git a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/ColorDropdown.kt b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/ColorDropdown.kt index 2e65b8c..b960ceb 100644 --- a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/ColorDropdown.kt +++ b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/ColorDropdown.kt @@ -53,9 +53,9 @@ import org.jetbrains.jewel.ui.util.thenIf fun ColorDropdown( selectedColor: Int, colors: List, + modifier: Modifier = Modifier, allowTransparency: Boolean = false, onSelected: (Int) -> Unit, - modifier: Modifier = Modifier ) { val focusManager = LocalFocusManager.current @@ -198,4 +198,4 @@ private fun DropdownButton( ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/DimensionInputField.kt b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/DimensionInputField.kt index d7f60b1..295196e 100644 --- a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/DimensionInputField.kt +++ b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/components/DimensionInputField.kt @@ -24,9 +24,9 @@ fun DimensionInputField( max: Int? = null, enabled: Boolean = false, paramName: String, - onUpdate: (Int) -> Unit, - trailingIcon: @Composable (() -> Unit)? = null, + onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, + trailingIcon: @Composable (() -> Unit)? = null, ) { val focusManager = LocalFocusManager.current val textFieldState = remember(value) { TextFieldState(value.toString()) } @@ -57,7 +57,7 @@ fun DimensionInputField( it } } - onUpdate(nextValue) + onValueChange(nextValue) textFieldState.edit { replace(0, textFieldState.text.length, nextValue.toString()) } } ?: run { reset() @@ -102,4 +102,4 @@ fun DimensionInputField( return@onKeyEvent true } ) -} \ No newline at end of file +} diff --git a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/data/AppState.kt b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/data/AppState.kt index 33b8b8c..91f710c 100644 --- a/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/data/AppState.kt +++ b/composeApp/src/desktopMain/kotlin/des/c5inco/mesh/ui/data/AppState.kt @@ -15,8 +15,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.awt.image.BufferedImage import java.io.File @@ -66,8 +64,7 @@ object AppState { var constrainEdgePoints by mutableStateOf(true) val colors = defaultColors.toMutableStateList() var canvasBackgroundColor: Int by mutableStateOf(-1) - private val _canvasWidthMode = MutableStateFlow(DimensionMode.Fill) - val canvasWidthMode = _canvasWidthMode.asStateFlow() + var canvasSizeMode by mutableStateOf(DimensionMode.Fill) private val _notificationFlow = MutableSharedFlow() val notificationFlow = _notificationFlow @@ -78,25 +75,6 @@ object AppState { } } - fun updateCanvasWidthMode() { - _canvasWidthMode.value = if (_canvasWidthMode.value == DimensionMode.Fill) { - DimensionMode.Fixed - } else { - DimensionMode.Fill - } - } - - private val _canvasHeightMode = MutableStateFlow(DimensionMode.Fill) - val canvasHeightMode = _canvasHeightMode.asStateFlow() - - fun updateCanvasHeightMode() { - _canvasHeightMode.value = if (_canvasHeightMode.value == DimensionMode.Fill) { - DimensionMode.Fixed - } else { - DimensionMode.Fill - } - } - var canvasWidth: Int by mutableStateOf(200) var canvasHeight: Int by mutableStateOf(100) var colorPointsRows by mutableStateOf(3) @@ -273,4 +251,4 @@ object AppState { e.printStackTrace() } } -} \ No newline at end of file +}