From f002f1672d825af3f8f92479890a405aa7b9c0f6 Mon Sep 17 00:00:00 2001 From: Sebastiano Poggi Date: Thu, 6 Mar 2025 16:33:32 +0100 Subject: [PATCH] Improve canvas sizing mode, fix preview A/R This commit changes the canvas sizing mode; it is now going to either fill available space, or have a fixed size, on both directions. This makes it behave more intuitively, and allows us to properly respect the canvas aspect ratio in the preview. This is reflected in the UI. That change also includes a bunch of cleanup (including addressing Lint complaints) of the project code. It also includes a change to how points are drawn in the gradient, to avoid the points being squashed for non-square canvases. Lastly, it adds an explicit output image size in the export scale selector, allowing a better understanding of what each option means. --- .../des/c5inco/mesh/common/MeshGradient.kt | 40 ++-- .../desktopMain/kotlin/des/c5inco/mesh/App.kt | 13 +- .../des/c5inco/mesh/ui/GradientCanvas.kt | 81 ++++---- .../kotlin/des/c5inco/mesh/ui/SidePanel.kt | 176 ++++++++++++------ .../mesh/ui/components/ColorDropdown.kt | 4 +- .../mesh/ui/components/DimensionInputField.kt | 8 +- .../des/c5inco/mesh/ui/data/AppState.kt | 26 +-- 7 files changed, 190 insertions(+), 158 deletions(-) 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 +}