Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 192 additions & 7 deletions app/src/main/java/org/lineageos/canvas/models/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,23 @@

package org.lineageos.canvas.models

import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.LayoutDirection

/**
* An action to apply to an image.
Expand Down Expand Up @@ -35,20 +49,98 @@ sealed interface Action {
/**
* Transformations that will edit the dimensions of the image.
*/
sealed interface Transformation : Action {
sealed interface Transformation : Action, Transformable, Processable {
/**
* Resize the image to the given rectangle.
*
* @param rect The rectangle to resize to
* @param rect The rectangle to resize to, from the top-left corner
*/
data class Resize(val rect: IntRect) : Transformation
data class Resize(val rect: IntRect) : Transformation {
override fun DrawScope.applyTransform(block: DrawScope.() -> Unit) {
withTransform({
clipRect(
left = rect.left.toFloat(),
top = rect.top.toFloat(),
right = rect.right.toFloat(),
bottom = rect.bottom.toFloat(),
)
}, block)
}

override fun process(bitmap: Bitmap): Bitmap = Bitmap.createBitmap(
bitmap,
rect.left,
rect.top,
rect.width,
rect.height,
)
}

/**
* Rotate the image by the given degrees.
*
* @param rotation The rotation to apply
*/
data class Rotation(val rotation: RotationStep) : Transformation {
override fun DrawScope.applyTransform(block: DrawScope.() -> Unit) {
withTransform({
rotate(
degrees = rotation.degrees,
pivot = Offset(size.width / 2f, size.height / 2f)
)
translate(
left = (size.width - size.height) / 2f,
top = (size.height - size.width) / 2f,
)
}, block)
}

override fun process(bitmap: Bitmap): Bitmap {
val matrix = Matrix().apply {
postRotate(rotation.degrees)
}
return Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true,
)
}
}
}

sealed interface Drawing : Action {
sealed interface Drawing : Action, Drawable {
/**
* Draw a line on the image.
*
* @param points The points of the line
* @param strokeWidth The width of the line
* @param color The color of the line
*/
data object Marker : Drawing
data class Marker(
val points: List<Offset>,
val strokeWidth: Float,
val color: Color,
) : Drawing {
override fun DrawScope.draw(context: DrawingContext) = drawStroke(BlendMode.SrcOver)

fun DrawScope.drawStroke(blendMode: BlendMode) {
drawContext.canvas.saveLayer(size.toRect(), Paint())
for (i in 1 until points.size) {
drawLine(
color = color,
start = points[i - 1],
end = points[i],
strokeWidth = strokeWidth,
blendMode = blendMode,
)
}
drawContext.canvas.restore()
}
}

/**
* Add a text label to the image.
Expand All @@ -61,7 +153,22 @@ sealed interface Action {
val text: String,
val position: IntOffset,
val style: TextStyle,
) : Drawing
) : Drawing {
override fun DrawScope.draw(context: DrawingContext) {
val textMeasurer = TextMeasurer(
defaultFontFamilyResolver = context.fontFamilyResolver,
defaultDensity = this,
defaultLayoutDirection = context.layoutDirection,
)

drawText(
textMeasurer = textMeasurer,
text = text,
topLeft = Offset(position.x.toFloat(), position.y.toFloat()),
style = style,
)
}
}

/**
* Clear a [Marker] drawing from the image.
Expand All @@ -70,6 +177,84 @@ sealed interface Action {
*/
data class Eraser(
val marker: Marker,
) : Drawing
) : Drawing {
override fun DrawScope.draw(context: DrawingContext) {
with(marker) { drawStroke(BlendMode.Clear) }
}
}
}
}

data class DrawingContext(
val fontFamilyResolver: FontFamily.Resolver,
val layoutDirection: LayoutDirection,
)

fun interface Drawable {
fun DrawScope.draw(context: DrawingContext)
}

fun interface Transformable {
fun DrawScope.applyTransform(block: DrawScope.() -> Unit)
}

fun interface Processable {
fun process(bitmap: Bitmap): Bitmap
}

fun List<Action.Transformation>.process(bitmap: Bitmap) = fold(bitmap) { acc, t -> t.process(acc) }

fun DrawScope.applyActionsAndDraw(
sourceBitmap: ImageBitmap,
actions: List<Action>,
context: DrawingContext,
) {
var block: DrawScope.() -> Unit = {}

for (action in actions.asReversed()) {
val innerBlock = block
block = when (action) {
is Action.Transformation -> {
{ with(action) { applyTransform { innerBlock() } } }
}

is Action.Drawing -> {
{
innerBlock()
with(action) { draw(context) }
}
}

else -> {
innerBlock
}
}
}

drawImage(sourceBitmap)
block()
}

/**
* Image rotation.
*/
enum class RotationStep(val degrees: Float) {
ROT_0(0f),
ROT_90(90f),
ROT_180(180f),
ROT_270(270f);

fun clockwise() = when (this) {
ROT_0 -> ROT_90
ROT_90 -> ROT_180
ROT_180 -> ROT_270
ROT_270 -> ROT_0
}

fun counterClockwise() = when (this) {
ROT_0 -> ROT_270
ROT_90 -> ROT_0
ROT_180 -> ROT_90
ROT_270 -> ROT_180
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.lineageos.canvas.models.EditMode
import org.lineageos.canvas.ui.LocalSharedTransitionScope
import org.lineageos.canvas.ui.screens.HomeScreen
import org.lineageos.canvas.ui.screens.ResizeScreen
import org.lineageos.canvas.ui.screens.RotationScreen
import org.lineageos.canvas.ui.screens.TextScreen

@Composable
Expand Down Expand Up @@ -59,7 +60,16 @@ fun CanvasNavDisplay(
onCancel = navigationBackStack::removeLastOrNull,
)

EditMode.ROTATION -> TODO()
EditMode.ROTATION -> RotationScreen(
innerPadding = innerPadding,
imageBitmap = finalResultBitmap,
cropRect = cropRect,
onConfirm = { action ->
onAddAction(action)
navigationBackStack.removeLastOrNull()
},
onCancel = navigationBackStack::removeLastOrNull,
)

EditMode.MARKER -> TODO()

Expand Down
100 changes: 100 additions & 0 deletions app/src/main/java/org/lineageos/canvas/ui/screens/RotationScreen.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2026 The LineageOS Project
* SPDX-License-Identifier: Apache-2.0
*/

package org.lineageos.canvas.ui.screens

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.RotateLeft
import androidx.compose.material.icons.automirrored.filled.RotateRight
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp
import org.lineageos.canvas.models.Action
import org.lineageos.canvas.models.RotationStep
import org.lineageos.canvas.ui.composables.CanvasImage
import org.lineageos.canvas.ui.composables.SimpleActionBottomBar

@Composable
fun RotationScreen(
innerPadding: PaddingValues,
imageBitmap: ImageBitmap,
cropRect: IntRect?,
onConfirm: (Action) -> Unit,
onCancel: () -> Unit,
) {
var rotation by remember { mutableStateOf(RotationStep.ROT_0) }

Column {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CanvasImage(
imageBitmap = imageBitmap,
cropRect = cropRect,
modifier = Modifier
.fillMaxSize()
.padding(48.dp)
.graphicsLayer {
rotationZ = rotation.degrees
},
)
}

Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { rotation = rotation.counterClockwise() },
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.RotateLeft,
contentDescription = null,
)
}

IconButton(
onClick = { rotation = rotation.clockwise() },
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.RotateRight,
contentDescription = null,
)
}
}

SimpleActionBottomBar(
onConfirm = {
onConfirm(Action.Transformation.Rotation(rotation))
},
onCancel = onCancel,
)
}
}
Loading