chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:31 +08:00
commit f06de36198
4585 changed files with 588418 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<manifest>
</manifest>
@@ -0,0 +1,574 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BlurMaskFilter
import android.graphics.Matrix
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.StampedPathEffectStyle
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativePaint
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.LayoutDirection
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import com.t8rin.imagetoolbox.core.data.image.utils.drawBitmap
import com.t8rin.imagetoolbox.core.data.utils.density
import com.t8rin.imagetoolbox.core.data.utils.safeConfig
import com.t8rin.imagetoolbox.core.data.utils.toSoftware
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
import com.t8rin.imagetoolbox.core.domain.model.ImageModel
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.max
import com.t8rin.imagetoolbox.core.domain.transformation.Transformation
import com.t8rin.imagetoolbox.core.filters.domain.FilterProvider
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.filters.domain.model.createFilter
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
import com.t8rin.imagetoolbox.core.resources.shapes.MaterialStarShape
import com.t8rin.imagetoolbox.core.utils.toImageModel
import com.t8rin.imagetoolbox.core.utils.toTypeface
import com.t8rin.imagetoolbox.feature.draw.data.utils.drawRepeatedBitmapOnPath
import com.t8rin.imagetoolbox.feature.draw.data.utils.drawRepeatedTextOnPath
import com.t8rin.imagetoolbox.feature.draw.domain.DrawBehavior
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.domain.ImageDrawApplier
import com.t8rin.imagetoolbox.feature.draw.domain.PathPaint
import com.t8rin.trickle.WarpBrush
import com.t8rin.trickle.WarpEngine
import com.t8rin.trickle.WarpMode
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlin.math.roundToInt
import android.graphics.Paint as AndroidPaint
import android.graphics.Path as NativePath
internal class AndroidImageDrawApplier @Inject constructor(
@ApplicationContext private val context: Context,
private val imageTransformer: ImageTransformer<Bitmap>,
private val imageGetter: ImageGetter<Bitmap>,
private val filterProvider: FilterProvider<Bitmap>,
) : ImageDrawApplier<Bitmap, Path, Color> {
override suspend fun applyDrawToImage(
drawBehavior: DrawBehavior,
pathPaints: List<PathPaint<Path, Color>>,
imageUri: String
): Bitmap? {
val image: Bitmap? = when (drawBehavior) {
is DrawBehavior.Image -> {
imageGetter.getImage(data = imageUri)
}
is DrawBehavior.Background -> {
drawBehavior.color.toDrawable().toBitmap(
width = drawBehavior.width.coerceAtLeast(1),
height = drawBehavior.height.coerceAtLeast(1)
)
}
else -> null
}
val drawImage = image?.let {
createBitmap(it.width, it.height, it.safeConfig).apply { setHasAlpha(true) }
}
drawImage?.let { bitmap ->
bitmap.applyCanvas {
val canvasSize = IntegerSize(width, height)
(drawBehavior as? DrawBehavior.Background)?.apply { drawColor(color) }
pathPaints.forEach { (nonScaledPath, nonScaledStroke, radius, drawColor, isErasing, drawMode, size, drawPathMode, drawLineStyle) ->
val stroke = drawPathMode.convertStrokeWidth(
strokeWidth = nonScaledStroke,
canvasSize = canvasSize
)
val path = nonScaledPath.scaleToFitCanvas(
currentSize = canvasSize,
oldSize = size
)
val isSharpEdge = drawPathMode.isSharpEdge
val isFilled = drawPathMode.isFilled
if (drawMode is DrawMode.PathEffect && !isErasing) {
val paint = Paint().apply {
if (isFilled) {
style = PaintingStyle.Fill
} else {
style = PaintingStyle.Stroke
this.strokeWidth = stroke
if (isSharpEdge) {
strokeCap = StrokeCap.Square
} else {
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
}
}
color = Color.Transparent
blendMode = BlendMode.Clear
}
val shaderSource = imageTransformer.transform(
image = image.overlay(bitmap),
transformations = transformationsForMode(
canvasSize = canvasSize,
drawMode = drawMode
)
)?.asImageBitmap()?.clipBitmap(
path = path,
paint = paint
)
if (shaderSource != null) {
drawBitmap(shaderSource.asAndroidBitmap())
}
} else if (drawMode is DrawMode.SpotHeal && !isErasing) {
val paint = Paint().apply {
if (isFilled) {
style = PaintingStyle.Fill
} else {
style = PaintingStyle.Stroke
this.strokeWidth = stroke
if (isSharpEdge) {
strokeCap = StrokeCap.Square
} else {
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
}
}
color = Color.White
}
val filter = filterProvider.filterToTransformation(
createFilter<Pair<ImageModel, SpotHealMode>, Filter.SpotHeal>(
Pair(
createBitmap(
canvasSize.width,
canvasSize.height
).applyCanvas {
drawColor(Color.Black.toArgb())
drawPath(
path.asAndroidPath(),
paint.nativePaint
)
}.toImageModel(),
drawMode.mode
)
)
)
imageTransformer.transform(
image = image.overlay(bitmap),
transformations = listOf(filter)
)?.let {
drawBitmap(
it.asImageBitmap().clipBitmap(
path = path,
paint = paint.apply {
blendMode = BlendMode.Clear
}
).asAndroidBitmap()
)
}
} else if (drawMode is DrawMode.Warp && !isErasing) {
val engine = WarpEngine(image.overlay(bitmap))
try {
drawMode.strokes.forEach { warpStroke ->
val warp = warpStroke.scaleToFitCanvas(
currentSize = canvasSize,
oldSize = size
)
engine.applyStroke(
fromX = warp.fromX,
fromY = warp.fromY,
toX = warp.toX,
toY = warp.toY,
brush = WarpBrush(
radius = stroke,
strength = drawMode.strength,
hardness = drawMode.hardness
),
mode = WarpMode.valueOf(drawMode.warpMode.name)
)
}
drawBitmap(engine.render())
} finally {
engine.release()
}
} else {
val paint = Paint().apply {
if (isErasing) {
blendMode = BlendMode.Clear
style = PaintingStyle.Stroke
this.strokeWidth = stroke
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
} else {
if (drawMode !is DrawMode.Text) {
pathEffect = drawLineStyle.asPathEffect(
canvasSize = canvasSize,
strokeWidth = stroke
)
if (isFilled) {
style = PaintingStyle.Fill
} else {
style = PaintingStyle.Stroke
strokeWidth = stroke
if (drawMode is DrawMode.Highlighter || isSharpEdge) {
strokeCap = StrokeCap.Square
} else {
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
}
}
}
}
color = drawColor
alpha = drawColor.alpha
}.nativePaint.apply {
if (drawMode is DrawMode.Neon && !isErasing) {
this.color = Color.White.toArgb()
setShadowLayer(
radius.toPx(canvasSize),
0f,
0f,
drawColor
.copy(alpha = .8f)
.toArgb()
)
} else if (radius.value > 0f) {
maskFilter =
BlurMaskFilter(
radius.toPx(canvasSize),
BlurMaskFilter.Blur.NORMAL
)
}
if (drawMode is DrawMode.Text && !isErasing) {
isAntiAlias = true
textSize = stroke
typeface = drawMode.font.toTypeface()
}
}
val androidPath = path.asAndroidPath()
if (drawMode is DrawMode.Text && !isErasing) {
if (drawMode.isRepeated) {
drawRepeatedTextOnPath(
text = drawMode.text,
path = androidPath,
paint = paint,
interval = drawMode.repeatingInterval.toPx(canvasSize)
)
} else {
drawTextOnPath(drawMode.text, androidPath, 0f, 0f, paint)
}
} else if (drawMode is DrawMode.Image && !isErasing) {
imageGetter.getImage(
data = drawMode.imageData,
size = stroke.roundToInt()
)?.let {
drawRepeatedBitmapOnPath(
bitmap = it,
path = androidPath,
paint = paint,
interval = drawMode.repeatingInterval.toPx(canvasSize)
)
}
} else if (drawPathMode is DrawPathMode.Outlined) {
drawPathMode.fillColor?.let { fillColor ->
val filledPaint = AndroidPaint().apply {
set(paint)
style = AndroidPaint.Style.FILL
color = fillColor.colorInt
if (Color(fillColor.colorInt).alpha == 1f) {
alpha =
(drawColor.alpha * 255).roundToInt().coerceIn(0, 255)
}
pathEffect = null
}
drawPath(androidPath, filledPaint)
}
drawPath(androidPath, paint)
} else {
drawPath(androidPath, paint)
}
}
}
}
}
return drawImage?.let { image.overlay(it) }
}
override suspend fun applyEraseToImage(
pathPaints: List<PathPaint<Path, Color>>,
imageUri: String
): Bitmap? = applyEraseToImage(
pathPaints = pathPaints,
image = imageGetter.getImage(data = imageUri),
shaderSourceUri = imageUri
)
override suspend fun applyEraseToImage(
pathPaints: List<PathPaint<Path, Color>>,
image: Bitmap?,
shaderSourceUri: String
): Bitmap? = image?.let { it.copy(it.safeConfig, true) }?.let { bitmap ->
bitmap.applyCanvas {
val canvasSize = IntegerSize(width, height)
drawBitmap(bitmap)
val recoveryShader = imageGetter.getImage(
data = shaderSourceUri
)?.asImageBitmap()?.let { bmp -> ImageShader(bmp) }
pathPaints.forEach { (nonScaledPath, stroke, radius, _, isRecoveryOn, _, size, mode) ->
val path = nonScaledPath.scaleToFitCanvas(
currentSize = canvasSize,
oldSize = size
)
drawPath(
path.asAndroidPath(),
Paint().apply {
if (mode.isFilled) {
style = PaintingStyle.Fill
} else {
style = PaintingStyle.Stroke
this.strokeWidth = mode.convertStrokeWidth(
strokeWidth = stroke,
canvasSize = canvasSize
)
if (mode.isSharpEdge) {
strokeCap = StrokeCap.Square
} else {
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
}
}
if (isRecoveryOn) {
shader = recoveryShader
} else {
blendMode = BlendMode.Clear
}
}.nativePaint.apply {
if (radius.value > 0f) {
maskFilter =
BlurMaskFilter(
radius.toPx(canvasSize),
BlurMaskFilter.Blur.NORMAL
)
}
}
)
}
}
}
private fun transformationsForMode(
canvasSize: IntegerSize,
drawMode: DrawMode
): List<Transformation<Bitmap>> = when (drawMode) {
is DrawMode.PathEffect.PrivacyBlur -> {
listOf(
createFilter<Float, Filter.NativeStackBlur>(
drawMode.blurRadius.toFloat() / 1000 * max(canvasSize)
)
)
}
is DrawMode.PathEffect.Pixelation -> {
listOf(
createFilter<Float, Filter.NativeStackBlur>(
20.toFloat() / 1000 * max(canvasSize)
),
createFilter<Float, Filter.Pixelation>(
drawMode.pixelSize / 1000 * max(canvasSize)
)
)
}
is DrawMode.PathEffect.Custom -> drawMode.filter?.let {
listOf(it)
} ?: emptyList()
else -> emptyList()
}.map {
filterProvider.filterToTransformation(it)
}
private fun DrawLineStyle.asPathEffect(
canvasSize: IntegerSize,
strokeWidth: Float
): PathEffect? = when (this) {
is DrawLineStyle.Dashed -> {
PathEffect.dashPathEffect(
intervals = floatArrayOf(
size.toPx(canvasSize),
gap.toPx(canvasSize) + strokeWidth
),
phase = 0f
)
}
DrawLineStyle.DotDashed -> {
val dashOnInterval1 = strokeWidth * 4
val dashOffInterval1 = strokeWidth * 2
val dashOnInterval2 = strokeWidth / 4
val dashOffInterval2 = strokeWidth * 2
PathEffect.dashPathEffect(
intervals = floatArrayOf(
dashOnInterval1,
dashOffInterval1,
dashOnInterval2,
dashOffInterval2
),
phase = 0f
)
}
is DrawLineStyle.Stamped<*> -> {
fun Shape.toPath(): Path = Path().apply {
addOutline(
createOutline(
size = Size(strokeWidth, strokeWidth),
layoutDirection = LayoutDirection.Ltr,
density = context.density
)
)
}
val path: Path? = when (shape) {
is Shape -> shape.toPath()
is NativePath -> shape.asComposePath()
is Path -> shape
null -> MaterialStarShape.toPath()
else -> null
}
path?.let {
PathEffect.stampedPathEffect(
shape = it,
advance = spacing.toPx(canvasSize) + strokeWidth,
phase = 0f,
style = StampedPathEffectStyle.Morph
)
}
}
is DrawLineStyle.ZigZag -> {
val zigZagPath = Path().apply {
val zigZagLineWidth = strokeWidth / heightRatio
val shapeVerticalOffset = (strokeWidth / 2) / 2
val shapeHorizontalOffset = (strokeWidth / 2) / 2
moveTo(0f, 0f)
lineTo(strokeWidth / 2, strokeWidth / 2)
lineTo(strokeWidth, 0f)
lineTo(strokeWidth, 0f + zigZagLineWidth)
lineTo(strokeWidth / 2, strokeWidth / 2 + zigZagLineWidth)
lineTo(0f, 0f + zigZagLineWidth)
translate(Offset(-shapeHorizontalOffset, -shapeVerticalOffset))
}
PathEffect.stampedPathEffect(
shape = zigZagPath,
advance = strokeWidth,
phase = 0f,
style = StampedPathEffectStyle.Morph
)
}
DrawLineStyle.None -> null
}
private fun ImageBitmap.clipBitmap(
path: Path,
paint: Paint,
): ImageBitmap {
val newPath = NativePath(path.asAndroidPath())
return asAndroidBitmap().copy(Bitmap.Config.ARGB_8888, true).applyCanvas {
drawPath(
newPath.apply {
fillType = NativePath.FillType.INVERSE_WINDING
},
paint.nativePaint
)
}.asImageBitmap()
}
private fun Bitmap.overlay(overlay: Bitmap): Bitmap {
val image = this
return createBitmap(
width = image.width,
height = image.height,
config = safeConfig.toSoftware()
).applyCanvas {
drawBitmap(image)
drawBitmap(overlay.toSoftware())
}
}
private fun Path.scaleToFitCanvas(
currentSize: IntegerSize,
oldSize: IntegerSize,
onGetScale: (Float, Float) -> Unit = { _, _ -> }
): Path {
val sx = currentSize.width.toFloat() / oldSize.width
val sy = currentSize.height.toFloat() / oldSize.height
onGetScale(sx, sy)
return NativePath(this.asAndroidPath()).apply {
transform(
Matrix().apply {
setScale(sx, sy)
}
)
}.asComposePath()
}
}
@@ -0,0 +1,97 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.data.utils
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PathMeasure
import androidx.core.graphics.withSave
import kotlin.math.atan2
fun Canvas.drawRepeatedTextOnPath(
text: String,
path: Path,
paint: Paint,
interval: Float = 0f
) {
val pathMeasure = PathMeasure(path, false)
val pathLength = pathMeasure.length
val textWidth = paint.measureText(text)
val fullRepeats = (pathLength / (textWidth + interval)).toInt()
val remainingLength = pathLength - fullRepeats * (textWidth + interval)
var distance = 0f
repeat(fullRepeats) {
drawTextOnPath(text, path, distance, 0f, paint)
distance += (textWidth + interval)
}
if (remainingLength > 0f) {
val ratio = (textWidth + interval - (remainingLength)) / (textWidth + interval)
val endOffset = (text.length - (text.length * ratio).toInt()).coerceAtLeast(0)
drawTextOnPath(text.substring(0, endOffset), path, distance, 0f, paint)
}
}
fun Canvas.drawRepeatedBitmapOnPath(
bitmap: Bitmap,
path: Path,
paint: Paint,
interval: Float = 0f
) {
val pathMeasure = PathMeasure(path, false)
val pathLength = pathMeasure.length
val bitmapWidth = bitmap.width.toFloat()
val bitmapHeight = bitmap.height.toFloat()
var distance = 0f
val matrix = Matrix()
while (distance < pathLength) {
val pos = FloatArray(2)
val tan = FloatArray(2)
pathMeasure.getPosTan(distance, pos, tan)
val degree = Math.toDegrees(atan2(tan[1].toDouble(), tan[0].toDouble())).toFloat()
withSave {
translate(pos[0], pos[1])
rotate(degree)
matrix.reset()
matrix.postTranslate(-bitmapWidth / 2, -bitmapHeight / 2)
matrix.postRotate(degree)
matrix.postTranslate(bitmapWidth / 2, bitmapHeight / 2)
drawBitmap(bitmap, matrix, paint)
}
if (interval < 0 && distance + bitmapWidth < 0) break
else {
distance += (bitmapWidth + interval)
}
}
}
@@ -0,0 +1,41 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.di
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import com.t8rin.imagetoolbox.feature.draw.data.AndroidImageDrawApplier
import com.t8rin.imagetoolbox.feature.draw.domain.ImageDrawApplier
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface DrawModule {
@Singleton
@Binds
fun provideImageDrawApplier(
applier: AndroidImageDrawApplier
): ImageDrawApplier<Bitmap, Path, Color>
}
@@ -0,0 +1,35 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
sealed class DrawBehavior(
open val orientation: Int
) {
data object None : DrawBehavior(2)
data class Image(
override val orientation: Int
) : DrawBehavior(orientation = orientation)
data class Background(
override val orientation: Int,
val width: Int,
val height: Int,
val color: Int
) : DrawBehavior(orientation = orientation)
}
@@ -0,0 +1,62 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.domain.model.pt
// Works only for Basic draw modes (excludes DrawMode.PathEffect, DrawMode.SpotHeal, DrawMode.Text, DrawMode.Image)
sealed class DrawLineStyle(
val ordinal: Int
) {
data object None : DrawLineStyle(0)
data class Dashed(
val size: Pt = 10.pt,
val gap: Pt = 20.pt
) : DrawLineStyle(1)
data class ZigZag(
val heightRatio: Float = 4f
) : DrawLineStyle(2)
data class Stamped<Shape>(
val shape: Shape? = null,
val spacing: Pt = 20.pt
) : DrawLineStyle(3)
data object DotDashed : DrawLineStyle(4)
companion object {
val entries by lazy {
listOf(
None,
Dashed(),
DotDashed,
ZigZag(),
Stamped<Any>(),
)
}
fun fromOrdinal(
ordinal: Int
): DrawLineStyle = entries.find {
it.ordinal == ordinal
} ?: None
}
}
@@ -0,0 +1,84 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
import com.t8rin.imagetoolbox.core.settings.domain.model.FontType
sealed class DrawMode(open val ordinal: Int) {
data object Neon : DrawMode(2)
data object Highlighter : DrawMode(3)
data object Pen : DrawMode(0)
sealed class PathEffect(override val ordinal: Int) : DrawMode(ordinal) {
data class PrivacyBlur(
val blurRadius: Int = 20
) : PathEffect(1)
data class Pixelation(
val pixelSize: Float = 35f
) : PathEffect(4)
data class Custom(
val filter: Filter<*>? = null
) : PathEffect(5)
}
data class Text(
val text: String = "Text",
val font: FontType? = null,
val isRepeated: Boolean = false,
val repeatingInterval: Pt = Pt.Zero
) : DrawMode(6)
data class Image(
val imageData: Any = "file:///android_asset/svg/emotions/aasparkles.svg",
val repeatingInterval: Pt = Pt.Zero
) : DrawMode(7)
data class SpotHeal(
val mode: SpotHealMode = SpotHealMode.OpenCV
) : DrawMode(8)
data class Warp(
val warpMode: WarpMode = WarpMode.MOVE,
val strength: Float = 0.25f,
val hardness: Float = 0.5f,
val strokes: List<WarpStroke> = emptyList(),
val previewClearToken: Long = 0L
) : DrawMode(9)
companion object {
val entries by lazy {
listOf(
Pen,
PathEffect.PrivacyBlur(),
SpotHeal(),
Warp(),
Neon,
Highlighter,
Text(),
Image(),
PathEffect.Pixelation(),
PathEffect.Custom()
)
}
}
}
@@ -0,0 +1,34 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
data class DrawOnBackgroundParams(
val width: Int,
val height: Int,
val color: Int?,
) {
companion object {
val Default by lazy {
DrawOnBackgroundParams(
width = -1,
height = -1,
color = null
)
}
}
}
@@ -0,0 +1,212 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
import com.t8rin.imagetoolbox.core.domain.model.ColorModel
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.domain.model.pt
import com.t8rin.imagetoolbox.core.domain.utils.safeCast
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.FloodFill
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Lasso
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.OutlinedOval
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.OutlinedRect
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Oval
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Polygon
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Rect
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Spray
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Star
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode.Triangle
sealed class DrawPathMode(
val ordinal: Int
) {
sealed class Outlined(ordinal: Int) : DrawPathMode(ordinal) {
abstract val fillColor: ColorModel?
}
data object Free : DrawPathMode(0)
data object Line : DrawPathMode(1)
data class PointingArrow(
val sizeScale: Float = 3f,
val angle: Float = 150f
) : DrawPathMode(2)
data class DoublePointingArrow(
val sizeScale: Float = 3f,
val angle: Float = 150f
) : DrawPathMode(3)
data class LinePointingArrow(
val sizeScale: Float = 3f,
val angle: Float = 150f
) : DrawPathMode(4)
data class DoubleLinePointingArrow(
val sizeScale: Float = 3f,
val angle: Float = 150f
) : DrawPathMode(5)
data object Lasso : DrawPathMode(6)
data class OutlinedRect(
val rotationDegrees: Int = 0,
val cornerRadius: Float = 0f,
override val fillColor: ColorModel? = null
) : Outlined(7)
data class OutlinedOval(
override val fillColor: ColorModel? = null
) : Outlined(8)
data class Rect(
val rotationDegrees: Int = 0,
val cornerRadius: Float = 0f
) : DrawPathMode(9)
data object Oval : DrawPathMode(10)
data object Triangle : DrawPathMode(11)
data class OutlinedTriangle(
override val fillColor: ColorModel? = null
) : Outlined(12)
data class Polygon(
val vertices: Int = 5,
val rotationDegrees: Int = 0,
val isRegular: Boolean = false
) : DrawPathMode(13)
data class OutlinedPolygon(
val vertices: Int = 5,
val rotationDegrees: Int = 0,
val isRegular: Boolean = false,
override val fillColor: ColorModel? = null
) : Outlined(14)
data class Star(
val vertices: Int = 5,
val rotationDegrees: Int = 0,
val innerRadiusRatio: Float = 0.5f,
val isRegular: Boolean = false
) : DrawPathMode(15)
data class OutlinedStar(
val vertices: Int = 5,
val rotationDegrees: Int = 0,
val innerRadiusRatio: Float = 0.5f,
val isRegular: Boolean = false,
override val fillColor: ColorModel? = null
) : Outlined(16)
data class FloodFill(
val tolerance: Float = 0.25f
) : DrawPathMode(17) {
companion object {
val StrokeSize = 2f.pt
}
}
data class Spray(
val density: Int = 50,
val pixelSize: Float = 1f,
val isSquareShaped: Boolean = false
) : DrawPathMode(18)
val canChangeStrokeWidth: Boolean
get() = this !is FloodFill && (!isFilled || this is Spray)
val isFilled: Boolean
get() = filled.any { this::class.isInstance(it) }
val outlinedFillColor: ColorModel?
get() = this.safeCast<Outlined>()?.fillColor
val isSharpEdge: Boolean
get() = sharp.any { this::class.isInstance(it) }
companion object {
val entries by lazy {
listOf(
Free,
FloodFill(),
Spray(),
Line,
PointingArrow(),
DoublePointingArrow(),
LinePointingArrow(),
DoubleLinePointingArrow(),
Lasso,
OutlinedRect(),
OutlinedOval(),
OutlinedTriangle(),
OutlinedPolygon(),
OutlinedStar(),
Rect(),
Oval,
Triangle,
Polygon(),
Star()
)
}
val outlinedEntries by lazy {
listOf(
OutlinedRect(),
OutlinedOval(),
OutlinedTriangle(),
OutlinedPolygon(),
OutlinedStar()
)
}
fun fromOrdinal(
ordinal: Int
): DrawPathMode = entries.find {
it.ordinal == ordinal
} ?: Free
}
fun convertStrokeWidth(
strokeWidth: Pt,
canvasSize: IntegerSize
): Float = when (this) {
is FloodFill -> FloodFill.StrokeSize.toPx(canvasSize)
else -> strokeWidth.toPx(canvasSize)
}
}
private val filled = listOf(
Lasso,
Rect(),
Oval,
Triangle,
Polygon(),
Star(),
Spray()
)
private val sharp = listOf(
OutlinedRect(),
OutlinedOval(),
Rect(),
Oval,
Lasso,
FloodFill(),
Spray()
)
@@ -0,0 +1,39 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
interface ImageDrawApplier<Image, Path, Color> {
suspend fun applyDrawToImage(
drawBehavior: DrawBehavior,
pathPaints: List<PathPaint<Path, Color>>,
imageUri: String
): Image?
suspend fun applyEraseToImage(
pathPaints: List<PathPaint<Path, Color>>,
imageUri: String
): Image?
suspend fun applyEraseToImage(
pathPaints: List<PathPaint<Path, Color>>,
image: Image?,
shaderSourceUri: String
): Image?
}
@@ -0,0 +1,44 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Pt
interface PathPaint<Path, Color> {
operator fun component1() = path
operator fun component2() = strokeWidth
operator fun component3() = brushSoftness
operator fun component4() = drawColor
operator fun component5() = isErasing
operator fun component6() = drawMode
operator fun component7() = canvasSize
operator fun component8() = drawPathMode
operator fun component9() = drawLineStyle
val path: Path
val strokeWidth: Pt
val brushSoftness: Pt
val drawColor: Color
val isErasing: Boolean
val drawMode: DrawMode
val canvasSize: IntegerSize
val drawPathMode: DrawPathMode
val drawLineStyle: DrawLineStyle
}
@@ -0,0 +1,50 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.domain
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
enum class WarpMode {
MOVE,
GROW,
SHRINK,
SWIRL_CW,
SWIRL_CCW,
MIXING
}
data class WarpStroke(
val fromX: Float,
val fromY: Float,
val toX: Float,
val toY: Float
) {
fun scaleToFitCanvas(
currentSize: IntegerSize,
oldSize: IntegerSize
): WarpStroke {
val sx = currentSize.width.toFloat() / oldSize.width
val sy = currentSize.height.toFloat() / oldSize.height
return copy(
fromX = fromX * sx,
fromY = fromY * sy,
toX = toX * sx,
toY = toY * sy
)
}
}
@@ -0,0 +1,383 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation
import android.net.Uri
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.SheetValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.dynamic.theme.LocalDynamicThemeState
import com.t8rin.imagetoolbox.core.domain.model.coerceIn
import com.t8rin.imagetoolbox.core.domain.model.pt
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Delete
import com.t8rin.imagetoolbox.core.resources.icons.Tune
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSettingsState
import com.t8rin.imagetoolbox.core.settings.presentation.provider.rememberAppColorTuple
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.Picker
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.rememberImagePicker
import com.t8rin.imagetoolbox.core.ui.utils.helper.Clipboard
import com.t8rin.imagetoolbox.core.ui.utils.helper.isPortraitOrientationAsState
import com.t8rin.imagetoolbox.core.ui.utils.provider.LocalScreenSize
import com.t8rin.imagetoolbox.core.ui.widget.AdaptiveBottomScaffoldLayoutScreen
import com.t8rin.imagetoolbox.core.ui.widget.buttons.BottomButtonsBlock
import com.t8rin.imagetoolbox.core.ui.widget.buttons.ShareButton
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.ExitWithoutSavingDialog
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.LoadingDialog
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.OneTimeImagePickingDialog
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.OneTimeSaveLocationSelectionDialog
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedIconButton
import com.t8rin.imagetoolbox.core.ui.widget.other.DrawLockScreenOrientation
import com.t8rin.imagetoolbox.core.ui.widget.other.TopAppBarEmoji
import com.t8rin.imagetoolbox.core.ui.widget.saver.ColorSaver
import com.t8rin.imagetoolbox.core.ui.widget.saver.PtSaver
import com.t8rin.imagetoolbox.core.ui.widget.sheets.ProcessImagesPreferenceSheet
import com.t8rin.imagetoolbox.core.ui.widget.text.TopAppBarTitle
import com.t8rin.imagetoolbox.core.ui.widget.utils.AutoContentBasedColors
import com.t8rin.imagetoolbox.feature.draw.domain.DrawBehavior
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.BitmapDrawer
import com.t8rin.imagetoolbox.feature.draw.presentation.components.controls.DrawContentControls
import com.t8rin.imagetoolbox.feature.draw.presentation.components.controls.DrawContentNoDataControls
import com.t8rin.imagetoolbox.feature.draw.presentation.components.controls.DrawContentSecondaryControls
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
import kotlinx.coroutines.launch
@Composable
fun DrawContent(
component: DrawComponent,
) {
val settingsState = LocalSettingsState.current
val themeState = LocalDynamicThemeState.current
val appColorTuple = rememberAppColorTuple()
val scope = rememberCoroutineScope()
var showExitDialog by rememberSaveable { mutableStateOf(false) }
val onBack = {
when (component.drawBehavior) {
!is DrawBehavior.None if component.haveChanges -> showExitDialog = true
!is DrawBehavior.None -> {
component.resetDrawBehavior()
themeState.updateColorTuple(appColorTuple)
}
else -> component.onGoBack()
}
}
AutoContentBasedColors(component.imageBitmap)
val imagePicker = rememberImagePicker { uri: Uri ->
component.setUri(uri)
}
val pickImage = imagePicker::pickImage
val saveBitmap: (oneTimeSaveLocationUri: String?) -> Unit = {
component.saveBitmap(
oneTimeSaveLocationUri = it
)
}
val screenSize = LocalScreenSize.current
val isPortrait by isPortraitOrientationAsState()
var panEnabled by rememberSaveable(component.drawBehavior) { mutableStateOf(false) }
var strokeWidth by rememberSaveable(
component.drawBehavior,
stateSaver = PtSaver
) { mutableStateOf(settingsState.defaultDrawLineWidth.pt) }
var drawColor by rememberSaveable(
component.drawBehavior,
stateSaver = ColorSaver
) { mutableStateOf(settingsState.defaultDrawColor) }
var isEraserOn by rememberSaveable(component.drawBehavior) { mutableStateOf(false) }
var showLineAngle by rememberSaveable(component.drawBehavior) { mutableStateOf(false) }
val drawMode = component.drawMode
var alpha by rememberSaveable(component.drawBehavior, drawMode) {
mutableFloatStateOf(if (drawMode is DrawMode.Highlighter) 0.4f else 1f)
}
var brushSoftness by rememberSaveable(component.drawBehavior, drawMode, stateSaver = PtSaver) {
mutableStateOf(if (drawMode is DrawMode.Neon) 35.pt else 0.pt)
}
val drawPathMode = component.drawPathMode
val drawLineStyle = component.drawLineStyle
LaunchedEffect(drawMode, strokeWidth) {
strokeWidth = if (drawMode is DrawMode.Image) {
strokeWidth.coerceIn(10.pt, 120.pt)
} else {
strokeWidth.coerceIn(1.pt, 100.pt)
}
}
val secondaryControls = @Composable {
DrawContentSecondaryControls(
component = component,
panEnabled = panEnabled,
onTogglePanEnabled = { panEnabled = !panEnabled },
isEraserOn = isEraserOn,
onToggleIsEraserOn = { isEraserOn = !isEraserOn }
)
}
val imageBitmap =
component.imageBitmap ?: (component.drawBehavior as? DrawBehavior.Background)?.run {
remember(width, height) { ImageBitmap(width, height) }
} ?: remember {
ImageBitmap(
screenSize.widthPx,
screenSize.heightPx
)
}
var showOneTimeImagePickingDialog by rememberSaveable {
mutableStateOf(false)
}
AdaptiveBottomScaffoldLayoutScreen(
title = {
TopAppBarTitle(
title = stringResource(R.string.draw),
input = component.drawBehavior.takeIf { it !is DrawBehavior.None },
isLoading = component.isImageLoading,
size = null,
originalSize = null
)
},
onGoBack = onBack,
shouldDisableBackHandler = component.drawBehavior is DrawBehavior.None,
actions = {
secondaryControls()
},
topAppBarPersistentActions = { scaffoldState ->
if (component.drawBehavior == DrawBehavior.None) TopAppBarEmoji()
else {
if (isPortrait) {
EnhancedIconButton(
onClick = {
scope.launch {
if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) {
scaffoldState.bottomSheetState.partialExpand()
} else {
scaffoldState.bottomSheetState.expand()
}
}
},
) {
Icon(
imageVector = Icons.Rounded.Tune,
contentDescription = stringResource(R.string.properties)
)
}
}
var editSheetData by remember {
mutableStateOf(listOf<Uri>())
}
ShareButton(
enabled = component.drawBehavior !is DrawBehavior.None,
onShare = component::shareBitmap,
onCopy = {
component.cacheCurrentImage(Clipboard::copy)
},
onEdit = {
component.cacheCurrentImage { uri ->
editSheetData = listOf(uri)
}
}
)
ProcessImagesPreferenceSheet(
uris = editSheetData,
visible = editSheetData.isNotEmpty(),
onDismiss = {
editSheetData = emptyList()
},
onNavigate = component.onNavigate
)
EnhancedIconButton(
onClick = component::clearDrawing,
enabled = component.drawBehavior !is DrawBehavior.None && component.havePaths
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.delete)
)
}
}
},
mainContent = {
AnimatedContent(
targetState = imageBitmap,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { imageBitmap ->
val direction = LocalLayoutDirection.current
val aspectRatio = imageBitmap.width / imageBitmap.height.toFloat()
BitmapDrawer(
imageBitmap = imageBitmap,
paths = component.paths,
strokeWidth = strokeWidth,
brushSoftness = brushSoftness,
drawColor = drawColor.copy(alpha),
onAddPath = component::addPath,
isEraserOn = isEraserOn,
drawMode = drawMode,
modifier = Modifier
.padding(
start = WindowInsets
.displayCutout
.asPaddingValues()
.calculateStartPadding(direction)
)
.padding(16.dp)
.aspectRatio(aspectRatio, isPortrait)
.fillMaxSize(),
panEnabled = panEnabled,
onRequestFiltering = component::filter,
drawPathMode = drawPathMode,
backgroundColor = component.backgroundColor,
drawLineStyle = drawLineStyle,
helperGridParams = component.helperGridParams,
showLineAngle = showLineAngle,
spotHealCache = component.spotHealCache,
onCacheSpotHealPathResult = component::cacheSpotHealPathResult,
onRemovePath = component::removePath
)
}
},
controls = {
DrawContentControls(
component = component,
secondaryControls = secondaryControls,
drawColor = drawColor,
onDrawColorChange = { drawColor = it },
strokeWidth = strokeWidth,
onStrokeWidthChange = { strokeWidth = it },
brushSoftness = brushSoftness,
onBrushSoftnessChange = { brushSoftness = it },
alpha = alpha,
onAlphaChange = { alpha = it },
showLineAngle = showLineAngle,
onShowLineAngleChange = { showLineAngle = it }
)
},
buttons = {
var showFolderSelectionDialog by rememberSaveable {
mutableStateOf(false)
}
BottomButtonsBlock(
isNoData = component.drawBehavior is DrawBehavior.None,
onSecondaryButtonClick = pickImage,
onSecondaryButtonLongClick = {
showOneTimeImagePickingDialog = true
},
isSecondaryButtonVisible = component.drawBehavior !is DrawBehavior.Background,
onPrimaryButtonClick = {
saveBitmap(null)
},
isPrimaryButtonVisible = component.drawBehavior !is DrawBehavior.None,
onPrimaryButtonLongClick = {
showFolderSelectionDialog = true
},
actions = {
if (isPortrait) it()
},
showNullDataButtonAsContainer = true,
drawBothStrokes = true
)
OneTimeSaveLocationSelectionDialog(
visible = showFolderSelectionDialog,
onDismiss = { showFolderSelectionDialog = false },
onSaveRequest = saveBitmap,
formatForFilenameSelection = component.getFormatForFilenameSelection()
)
OneTimeImagePickingDialog(
onDismiss = { showOneTimeImagePickingDialog = false },
picker = Picker.Single,
imagePicker = imagePicker,
visible = showOneTimeImagePickingDialog
)
},
enableNoDataScroll = false,
noDataControls = {
DrawContentNoDataControls(
component = component,
onPickImage = pickImage
)
},
canShowScreenData = component.drawBehavior !is DrawBehavior.None,
showActionsInTopAppBar = false,
mainContentWeight = 0.65f
)
LoadingDialog(
visible = component.isSaving || component.isImageLoading,
onCancelLoading = component::cancelSaving,
canCancel = component.isSaving
)
ExitWithoutSavingDialog(
onExit = {
if (component.drawBehavior !is DrawBehavior.None) {
component.resetDrawBehavior()
themeState.updateColorTuple(appColorTuple)
} else component.onGoBack()
},
onDismiss = { showExitDialog = false },
visible = showExitDialog
)
DrawLockScreenOrientation()
}
@@ -0,0 +1,720 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.PorterDuff
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.geometry.takeOrElse
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.core.graphics.createBitmap
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.domain.model.pt
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSettingsState
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.createScaledBitmap
import com.t8rin.imagetoolbox.core.ui.widget.modifier.HelperGridParams
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.domain.WarpStroke
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.LineAngleIndicator
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.BitmapDrawerPreview
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.DrawPathEffectPreview
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.MotionEvent
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.copy
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedImageOnPath
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedTextOnPath
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.floodFill
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.handle
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.overlay
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.pointerDrawObserver
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rememberPaint
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rememberPathHelper
import com.t8rin.trickle.WarpBrush
import com.t8rin.trickle.WarpEngine
import com.t8rin.trickle.WarpMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.ZoomState
import net.engawapg.lib.zoomable.rememberZoomState
import kotlin.math.roundToInt
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Paint as AndroidPaint
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun BitmapDrawer(
imageBitmap: ImageBitmap,
onRequestFiltering: suspend (Bitmap, List<Filter<*>>) -> Bitmap?,
paths: List<UiPathPaint>,
brushSoftness: Pt,
zoomState: ZoomState = rememberZoomState(maxScale = 30f),
onAddPath: (UiPathPaint) -> Unit,
strokeWidth: Pt,
isEraserOn: Boolean,
drawMode: DrawMode,
modifier: Modifier,
drawPathMode: DrawPathMode = DrawPathMode.Free,
onDrawStart: (() -> Unit)? = null,
onDraw: ((Bitmap) -> Unit)? = null,
onDrawFinish: (() -> Unit)? = null,
backgroundColor: Color,
panEnabled: Boolean,
drawColor: Color,
drawLineStyle: DrawLineStyle = DrawLineStyle.None,
helperGridParams: HelperGridParams = remember { HelperGridParams() },
showLineAngle: Boolean = false,
spotHealCache: Map<Int, Bitmap> = emptyMap(),
onCacheSpotHealPathResult: (Int, Bitmap) -> Unit = { _, _ -> },
onRemovePath: (UiPathPaint) -> Unit = {},
) {
val scope = rememberCoroutineScope()
val settingsState = LocalSettingsState.current
val magnifierEnabled by remember(zoomState.scale, settingsState.magnifierEnabled) {
derivedStateOf {
zoomState.scale <= 3f && !panEnabled && settingsState.magnifierEnabled
}
}
val globalTouchPointersCount = remember { mutableIntStateOf(0) }
var currentDrawPosition by remember { mutableStateOf(Offset.Unspecified) }
Box(
modifier = Modifier.pointerDrawObserver(
magnifierEnabled = magnifierEnabled,
currentDrawPosition = currentDrawPosition,
zoomState = zoomState,
globalTouchPointersCount = globalTouchPointersCount,
panEnabled = panEnabled
),
contentAlignment = Alignment.Center
) {
BoxWithConstraints(modifier) {
val motionEvent = remember { mutableStateOf(MotionEvent.Idle) }
var previousDrawPosition by remember { mutableStateOf(Offset.Unspecified) }
var drawDownPosition by remember { mutableStateOf(Offset.Unspecified) }
val imageWidth = constraints.maxWidth
val imageHeight = constraints.maxHeight
val drawImageBitmap by produceState<ImageBitmap?>(
initialValue = null,
imageBitmap,
imageWidth,
imageHeight,
backgroundColor
) {
value = null
value = withContext(Dispatchers.Default) {
imageBitmap.asAndroidBitmap().createScaledBitmap(
width = imageWidth,
height = imageHeight
).apply {
val canvas = AndroidCanvas(this)
val paint = android.graphics.Paint().apply {
color = backgroundColor.toArgb()
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}.asImageBitmap()
}
}
val drawBitmap: ImageBitmap by remember(imageWidth, imageHeight) {
derivedStateOf {
createBitmap(imageWidth, imageHeight).asImageBitmap()
}
}
var invalidations by remember {
mutableIntStateOf(0)
}
val needsDrawPathBitmap = !isEraserOn && (
drawMode is DrawMode.PathEffect
|| drawMode is DrawMode.SpotHeal
|| drawMode is DrawMode.Warp
)
val drawPathBitmap: ImageBitmap? by remember(
imageWidth,
imageHeight,
invalidations,
needsDrawPathBitmap
) {
derivedStateOf {
if (needsDrawPathBitmap) {
createBitmap(imageWidth, imageHeight).asImageBitmap()
} else null
}
}
LaunchedEffect(
paths,
drawMode,
backgroundColor,
drawPathMode,
imageWidth,
imageHeight
) {
invalidations++
}
val outputImage by remember(invalidations) {
derivedStateOf {
drawImageBitmap?.overlay(drawBitmap) ?: imageBitmap
}
}
val canvas: Canvas = remember(drawBitmap, imageHeight, imageWidth) {
Canvas(drawBitmap)
}
val drawPathCanvas = remember(drawPathBitmap, imageWidth, imageHeight) {
drawPathBitmap?.let(::Canvas)
}
val canvasSize by remember(canvas.nativeCanvas) {
derivedStateOf {
IntegerSize(
width = canvas.nativeCanvas.width,
height = canvas.nativeCanvas.height
)
}
}
val drawPaint by rememberPaint(
strokeWidth = strokeWidth,
isEraserOn = isEraserOn,
drawColor = drawColor,
brushSoftness = brushSoftness,
drawMode = drawMode,
canvasSize = canvasSize,
drawPathMode = drawPathMode,
drawLineStyle = drawLineStyle
)
var drawPath by remember(
drawMode,
strokeWidth,
isEraserOn,
drawColor,
brushSoftness,
drawPathMode
) { mutableStateOf(Path()) }
var pathWithoutTransformations by remember(
drawMode,
strokeWidth,
isEraserOn,
drawColor,
brushSoftness,
drawPathMode
) { mutableStateOf(Path()) }
var warpRuntimeStrokes by remember(drawMode) {
mutableStateOf(emptyList<WarpStroke>())
}
var warpClearTrigger by remember {
mutableIntStateOf(0)
}
var warpPreviewToken by remember {
mutableLongStateOf(0L)
}
var pendingWarpCommitToken by remember {
mutableLongStateOf(-1L)
}
var previousPathsCount by remember {
mutableIntStateOf(paths.size)
}
LaunchedEffect(paths.size) {
if (paths.isEmpty() || paths.size < previousPathsCount) {
warpClearTrigger++
pendingWarpCommitToken = -1L
}
previousPathsCount = paths.size
}
LaunchedEffect(drawMode, isEraserOn) {
if (drawMode !is DrawMode.Warp || isEraserOn) {
pendingWarpCommitToken = -1L
}
}
val isWarpInputLocked by remember(drawMode, isEraserOn, pendingWarpCommitToken) {
derivedStateOf {
drawMode is DrawMode.Warp && !isEraserOn && pendingWarpCommitToken >= 0L
}
}
with(canvas) {
with(nativeCanvas) {
drawColor(Color.Transparent.toArgb(), PorterDuff.Mode.CLEAR)
drawColor(backgroundColor.toArgb())
paths.forEach { uiPathPaint ->
UiPathPaintCanvasAction(
uiPathPaint = uiPathPaint,
invalidations = invalidations,
onInvalidate = { invalidations++ },
pathsCount = paths.size,
backgroundColor = backgroundColor,
drawImageBitmap = drawImageBitmap ?: imageBitmap,
drawBitmap = drawBitmap,
onClearDrawPath = {
drawPath = Path()
warpClearTrigger++
warpRuntimeStrokes = emptyList()
},
onClearWarpDrawPath = { token ->
if (token == pendingWarpCommitToken) {
drawPath = Path()
warpClearTrigger++
warpRuntimeStrokes = emptyList()
pendingWarpCommitToken = -1L
}
},
onRequestFiltering = onRequestFiltering,
spotHealCache = spotHealCache,
onCacheSpotHealPathResult = onCacheSpotHealPathResult,
onCancel = onRemovePath,
canvasSize = canvasSize,
scope = scope
)
}
if ((drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.Warp) || isEraserOn) {
val androidPath by remember(drawPath) {
derivedStateOf {
drawPath.asAndroidPath()
}
}
if (drawMode is DrawMode.Text && !isEraserOn) {
if (drawMode.isRepeated) {
drawRepeatedTextOnPath(
text = drawMode.text,
path = androidPath,
paint = drawPaint,
interval = drawMode.repeatingInterval.toPx(canvasSize)
)
} else {
drawTextOnPath(drawMode.text, androidPath, 0f, 0f, drawPaint)
}
} else if (drawMode is DrawMode.Image && !isEraserOn) {
drawRepeatedImageOnPath(
drawMode = drawMode,
strokeWidth = strokeWidth,
canvasSize = canvasSize,
path = androidPath,
paint = drawPaint,
invalidations = invalidations
)
} else if (drawMode is DrawMode.SpotHeal && !isEraserOn) {
drawPathCanvas?.nativeCanvas?.let {
with(it) {
drawColor(Color.Transparent.toArgb(), PorterDuff.Mode.CLEAR)
drawPath(
androidPath,
drawPaint.apply { color = Color.Red.copy(0.5f).toArgb() }
)
}
}
} else if (drawPathMode is DrawPathMode.Outlined) {
drawPathMode.fillColor?.let { fillColor ->
val filledPaint = remember(fillColor, drawPaint) {
AndroidPaint().apply {
set(drawPaint)
style = AndroidPaint.Style.FILL
color = fillColor.colorInt
if (Color(fillColor.colorInt).alpha == 1f) {
alpha =
(drawColor.alpha * 255).roundToInt()
.coerceIn(0, 255)
}
pathEffect = null
}
}
drawPath(androidPath, filledPaint)
}
drawPath(androidPath, drawPaint)
} else {
drawPath(androidPath, drawPaint)
}
}
}
val drawHelper by rememberPathHelper(
drawDownPosition = drawDownPosition,
currentDrawPosition = currentDrawPosition,
onPathChange = { drawPath = it },
strokeWidth = strokeWidth,
canvasSize = canvasSize,
drawPathMode = drawPathMode,
isEraserOn = isEraserOn,
drawMode = drawMode
)
motionEvent.value.handle(
onDown = {
if (drawMode is DrawMode.Warp && !isEraserOn) {
warpPreviewToken++
warpRuntimeStrokes = emptyList()
drawPath = Path()
pathWithoutTransformations = Path()
} else {
warpClearTrigger++
warpRuntimeStrokes = emptyList()
}
if (currentDrawPosition.isSpecified) {
onDrawStart?.invoke()
drawPath.moveTo(currentDrawPosition.x, currentDrawPosition.y)
previousDrawPosition = currentDrawPosition
pathWithoutTransformations = drawPath.copy()
} else {
drawPath = Path()
pathWithoutTransformations = Path()
}
motionEvent.value = MotionEvent.Idle
},
onMove = {
if (drawMode is DrawMode.Warp && !isEraserOn) {
if (
previousDrawPosition.isSpecified &&
currentDrawPosition.isSpecified
) {
warpRuntimeStrokes += WarpStroke(
fromX = previousDrawPosition.x,
fromY = previousDrawPosition.y,
toX = currentDrawPosition.x,
toY = currentDrawPosition.y
)
}
}
drawHelper.drawPath(
currentDrawPath = drawPath,
onDrawFreeArrow = {
if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) {
drawPath = Path().apply {
moveTo(
currentDrawPosition.x,
currentDrawPosition.y
)
}
pathWithoutTransformations = drawPath.copy()
previousDrawPosition = currentDrawPosition
}
if (previousDrawPosition.isSpecified && currentDrawPosition.isSpecified) {
drawPath = pathWithoutTransformations
drawPath.quadraticTo(
previousDrawPosition.x,
previousDrawPosition.y,
(previousDrawPosition.x + currentDrawPosition.x) / 2,
(previousDrawPosition.y + currentDrawPosition.y) / 2
)
previousDrawPosition = currentDrawPosition
pathWithoutTransformations = drawPath.copy()
drawArrowsIfNeeded(drawPath)
}
},
onBaseDraw = {
if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) {
drawPath.moveTo(currentDrawPosition.x, currentDrawPosition.y)
previousDrawPosition = currentDrawPosition
}
if (currentDrawPosition.isSpecified && previousDrawPosition.isSpecified) {
drawPath.quadraticTo(
previousDrawPosition.x,
previousDrawPosition.y,
(previousDrawPosition.x + currentDrawPosition.x) / 2,
(previousDrawPosition.y + currentDrawPosition.y) / 2
)
}
previousDrawPosition = currentDrawPosition
},
)
motionEvent.value = MotionEvent.Idle
},
onUp = {
if (currentDrawPosition.isSpecified && drawDownPosition.isSpecified) {
if (drawMode is DrawMode.Warp && warpRuntimeStrokes.isNotEmpty() && !isEraserOn) {
PathMeasure().apply {
setPath(drawPath, false)
}.let {
it.getPosition(it.length)
}.takeOrElse { currentDrawPosition }.let { lastPoint ->
warpRuntimeStrokes += WarpStroke(
fromX = lastPoint.x,
fromY = lastPoint.y,
toX = currentDrawPosition.x,
toY = currentDrawPosition.y
)
}
onAddPath(
UiPathPaint(
path = drawPath,
strokeWidth = strokeWidth,
brushSoftness = 0.pt,
drawColor = Color.Transparent,
isErasing = false,
drawMode = drawMode.copy(
strokes = warpRuntimeStrokes.toList(),
previewClearToken = warpPreviewToken
),
canvasSize = canvasSize,
drawPathMode = DrawPathMode.Free,
drawLineStyle = DrawLineStyle.None
)
)
pendingWarpCommitToken = warpPreviewToken
} else {
drawHelper.drawPath(
currentDrawPath = null,
onDrawFreeArrow = {
drawPath = pathWithoutTransformations
PathMeasure().apply {
setPath(drawPath, false)
}.let {
it.getPosition(it.length)
}.let { lastPoint ->
if (!lastPoint.isSpecified) {
drawPath.moveTo(
currentDrawPosition.x,
currentDrawPosition.y
)
}
drawPath.lineTo(
currentDrawPosition.x,
currentDrawPosition.y
)
}
drawArrowsIfNeeded(drawPath)
},
onBaseDraw = {
PathMeasure().apply {
setPath(drawPath, false)
}.let {
it.getPosition(it.length)
}.takeOrElse { currentDrawPosition }.let { lastPoint ->
drawPath.moveTo(lastPoint.x, lastPoint.y)
drawPath.lineTo(
currentDrawPosition.x,
currentDrawPosition.y
)
}
},
onFloodFill = { tolerance ->
outputImage
.floodFill(
offset = currentDrawPosition,
tolerance = tolerance
)
?.let { drawPath = it }
}
)
onAddPath(
UiPathPaint(
path = drawPath,
strokeWidth = strokeWidth,
brushSoftness = brushSoftness,
drawColor = drawColor,
isErasing = isEraserOn,
drawMode = drawMode,
canvasSize = canvasSize,
drawPathMode = drawPathMode,
drawLineStyle = drawLineStyle
)
)
}
}
currentDrawPosition = Offset.Unspecified
previousDrawPosition = Offset.Unspecified
motionEvent.value = MotionEvent.Idle
scope.launch {
if ((drawMode is DrawMode.PathEffect || drawMode is DrawMode.SpotHeal || drawMode is DrawMode.Warp) && !isEraserOn) Unit
else drawPath = Path()
pathWithoutTransformations = Path()
}
onDrawFinish?.invoke()
}
)
}
if (drawMode is DrawMode.PathEffect && !isEraserOn && drawPathCanvas != null) {
DrawPathEffectPreview(
drawPathCanvas = drawPathCanvas,
drawMode = drawMode,
canvasSize = canvasSize,
imageWidth = imageWidth,
imageHeight = imageHeight,
outputImage = outputImage,
onRequestFiltering = onRequestFiltering,
paths = paths,
drawPath = drawPath,
backgroundColor = backgroundColor,
strokeWidth = strokeWidth,
drawPathMode = drawPathMode
)
}
var warpEngine by remember {
mutableStateOf<WarpEngine?>(null)
}
LaunchedEffect(warpClearTrigger, drawMode) {
if (drawMode is DrawMode.Warp && !isEraserOn) {
warpEngine?.release()
warpEngine = WarpEngine(
src = outputImage.asAndroidBitmap()
)
} else {
warpEngine?.release()
warpEngine = null
}
}
DisposableEffect(Unit) {
onDispose {
warpEngine?.release()
warpEngine = null
}
}
LaunchedEffect(warpEngine) {
snapshotFlow { warpRuntimeStrokes.lastOrNull() }
.filterNotNull()
.collect {
val engine = warpEngine ?: return@collect
val warpMode = drawMode as? DrawMode.Warp ?: return@collect
engine.applyStroke(
fromX = it.fromX,
fromY = it.fromY,
toX = it.toX,
toY = it.toY,
brush = WarpBrush(
radius = strokeWidth.toPx(canvasSize),
strength = warpMode.strength,
hardness = warpMode.hardness
),
mode = WarpMode.valueOf(warpMode.warpMode.name)
)
invalidations++
}
}
val warpedImage by remember(invalidations, warpEngine) {
derivedStateOf {
warpEngine?.takeIf { warpRuntimeStrokes.isNotEmpty() }?.let { engine ->
engine.render().asImageBitmap().also {
it.prepareToDraw()
}
} ?: drawPathBitmap?.let(outputImage::overlay) ?: outputImage
}
}
val previewBitmap by remember(invalidations) {
derivedStateOf {
if (drawMode is DrawMode.Warp) {
warpedImage
} else {
drawPathBitmap?.let(outputImage::overlay) ?: outputImage
}
}
}
onDraw?.let {
LaunchedEffect(invalidations) {
onDraw(previewBitmap.asAndroidBitmap())
}
}
BitmapDrawerPreview(
preview = previewBitmap,
globalTouchPointersCount = globalTouchPointersCount,
onReceiveMotionEvent = { motionEvent.value = it },
onInvalidate = { invalidations++ },
onUpdateCurrentDrawPosition = { currentDrawPosition = it },
onUpdateDrawDownPosition = { drawDownPosition = it },
drawEnabled = !panEnabled && !isWarpInputLocked && drawImageBitmap != null,
helperGridParams = helperGridParams,
drawBitmapBorder = settingsState.drawBitmapBorder
)
if (showLineAngle && drawPathMode.canShowLineAngle() && drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
LineAngleIndicator(
drawDownPosition = drawDownPosition,
currentDrawPosition = currentDrawPosition,
imageWidth = imageWidth,
imageHeight = imageHeight,
isMagnifierEnabled = magnifierEnabled
)
}
}
}
}
@@ -0,0 +1,60 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Dots
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
@Composable
fun BrushSoftnessSelector(
modifier: Modifier,
value: Float,
color: Color = Color.Unspecified,
onValueChange: (Float) -> Unit
) {
EnhancedSliderItem(
modifier = modifier,
value = value,
title = stringResource(R.string.brush_softness),
valueRange = 0f..100f,
onValueChange = {
onValueChange(it.roundToTwoDigits())
},
valueSuffix = " Pt",
sliderModifier = Modifier
.padding(
top = 14.dp,
start = 12.dp,
end = 12.dp,
bottom = 10.dp
),
icon = Icons.Rounded.Dots,
shape = ShapeDefaults.extraLarge,
containerColor = color
)
}
@@ -0,0 +1,57 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.BrushColor
import com.t8rin.imagetoolbox.core.ui.widget.color_picker.ColorSelectionRowDefaults
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ColorRowSelector
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
@Composable
fun DrawColorSelector(
modifier: Modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
value: Color,
onValueChange: (Color) -> Unit,
color: Color = Color.Unspecified,
titleText: String = stringResource(R.string.paint_color),
defaultColors: List<Color> = ColorSelectionRowDefaults.colorList,
) {
ColorRowSelector(
value = value,
onValueChange = onValueChange,
modifier = modifier
.container(
shape = ShapeDefaults.extraLarge,
color = color
),
title = titleText,
allowAlpha = false,
icon = Icons.Outlined.BrushColor,
defaultColors = defaultColors
)
}
@@ -0,0 +1,54 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Line
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
@Composable
fun DrawLineAngleSelector(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
shape: androidx.compose.ui.graphics.Shape = ShapeDefaults.extraLarge,
startIcon: ImageVector? = Icons.Rounded.Line
) {
PreferenceRowSwitch(
modifier = modifier,
shape = shape,
title = stringResource(R.string.show_line_angle),
subtitle = stringResource(R.string.show_line_angle_sub),
checked = checked,
onClick = onCheckedChange,
startIcon = startIcon
)
}
fun DrawPathMode.canShowLineAngle(): Boolean {
return this is DrawPathMode.Line ||
this is DrawPathMode.LinePointingArrow ||
this is DrawPathMode.DoubleLinePointingArrow
}
@@ -0,0 +1,461 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.domain.model.pt
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.BoldLine
import com.t8rin.imagetoolbox.core.resources.icons.DashedLine
import com.t8rin.imagetoolbox.core.resources.icons.DotDashedLine
import com.t8rin.imagetoolbox.core.resources.icons.StampedLine
import com.t8rin.imagetoolbox.core.resources.icons.ZigzagLine
import com.t8rin.imagetoolbox.core.resources.shapes.CloverShape
import com.t8rin.imagetoolbox.core.resources.shapes.MaterialStarShape
import com.t8rin.imagetoolbox.core.resources.utils.animation.animateColorAsState
import com.t8rin.imagetoolbox.core.settings.presentation.model.IconShape
import com.t8rin.imagetoolbox.core.ui.widget.buttons.SupportingButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedFlingBehavior
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.hapticsClickable
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.modifier.fadingEdges
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
@Composable
fun DrawLineStyleSelector(
modifier: Modifier,
value: DrawLineStyle,
onValueChange: (DrawLineStyle) -> Unit,
values: List<DrawLineStyle> = DrawLineStyle.entries
) {
var isSheetVisible by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(values, value) {
if (values.filterIsInstance(value::class.java).isEmpty() && values.isNotEmpty()) {
onValueChange(values.first())
}
}
Column(
modifier = modifier
.container(ShapeDefaults.extraLarge),
horizontalAlignment = Alignment.CenterHorizontally
) {
EnhancedButtonGroup(
enabled = true,
itemCount = values.size,
title = {
Text(
text = stringResource(R.string.line_style),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.width(8.dp))
SupportingButton(
onClick = {
isSheetVisible = true
}
)
},
selectedIndex = values.indexOfFirst {
value::class.isInstance(it)
},
itemContent = {
Icon(
imageVector = values[it].getIcon(),
contentDescription = null
)
},
onIndexChange = {
onValueChange(values[it])
},
activeButtonColor = MaterialTheme.colorScheme.secondaryContainer
)
var lineStyle by remember {
mutableStateOf<DrawLineStyle?>(value)
}
AnimatedVisibility(
visible = value is DrawLineStyle.Dashed,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
SideEffect {
if (value is DrawLineStyle.Dashed) {
lineStyle = value
}
}
DisposableEffect(Unit) {
onDispose { lineStyle = null }
}
val style = lineStyle as? DrawLineStyle.Dashed ?: return@AnimatedVisibility
Column {
EnhancedSliderItem(
value = style.size.value,
title = stringResource(R.string.dash_size),
valueRange = 0f..100f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
style.copy(
size = it.pt
)
)
},
valueSuffix = " Pt",
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.top
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = style.gap.value,
title = stringResource(R.string.gap_size),
valueRange = 0f..100f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
style.copy(
gap = it.pt
)
)
},
valueSuffix = " Pt",
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.bottom
)
Spacer(modifier = Modifier.height(8.dp))
}
}
AnimatedVisibility(
visible = value is DrawLineStyle.ZigZag,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
SideEffect {
if (value is DrawLineStyle.ZigZag) {
lineStyle = value
}
}
DisposableEffect(Unit) {
onDispose { lineStyle = null }
}
val style = lineStyle as? DrawLineStyle.ZigZag ?: return@AnimatedVisibility
Column {
var ratio by remember {
mutableFloatStateOf(style.heightRatio)
}
EnhancedSliderItem(
value = ratio,
title = stringResource(R.string.zigzag_ratio),
valueRange = 0.5f..20f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
ratio = it
onValueChange(
style.copy(
heightRatio = 20.5f - it
)
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.default
)
Spacer(modifier = Modifier.height(8.dp))
}
}
AnimatedVisibility(
visible = value is DrawLineStyle.Stamped<*>,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
SideEffect {
if (value is DrawLineStyle.Stamped<*>) {
lineStyle = value
}
}
DisposableEffect(Unit) {
onDispose { lineStyle = null }
}
val style = lineStyle as? DrawLineStyle.Stamped<*> ?: return@AnimatedVisibility
val shape = if (style.shape is Shape) style.shape
else MaterialStarShape
Column {
val shapes = IconShape.entriesNoRandom
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.container(
shape = ShapeDefaults.top,
color = MaterialTheme.colorScheme.surface,
resultPadding = 0.dp
)
) {
val state = rememberLazyGridState()
LazyHorizontalGrid(
state = state,
rows = GridCells.Adaptive(48.dp),
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.fadingEdges(
scrollableState = state,
spanCount = 4,
length = 32.dp
),
verticalArrangement = Arrangement.spacedBy(
space = 6.dp,
alignment = Alignment.CenterVertically
),
horizontalArrangement = Arrangement.spacedBy(
space = 6.dp,
alignment = Alignment.CenterHorizontally
),
contentPadding = PaddingValues(8.dp),
flingBehavior = enhancedFlingBehavior()
) {
items(shapes) { iconShape ->
val selected by remember(iconShape, shape) {
derivedStateOf {
iconShape.shape == shape
}
}
val color by animateColorAsState(
if (selected) MaterialTheme.colorScheme.primaryContainer
else Color.Unspecified
)
val borderColor by animateColorAsState(
if (selected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f)
} else MaterialTheme.colorScheme.onSecondaryContainer.copy(
alpha = 0.1f
)
)
Box(
modifier = Modifier
.aspectRatio(1f)
.container(
color = color,
shape = CloverShape,
borderColor = borderColor,
resultPadding = 0.dp
)
.hapticsClickable {
onValueChange(
DrawLineStyle.Stamped(
shape = iconShape.shape,
spacing = style.spacing
)
)
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(30.dp)
.container(
borderWidth = 2.dp,
borderColor = MaterialTheme.colorScheme.onSurfaceVariant,
color = Color.Transparent,
shape = iconShape.shape
)
)
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = style.spacing.value,
title = stringResource(R.string.spacing),
valueRange = 0f..100f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
style.copy(
spacing = it.pt
)
)
},
valueSuffix = " Pt",
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.bottom
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
EnhancedModalBottomSheet(
sheetContent = {
Column(
modifier = Modifier
.enhancedVerticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
values.forEachIndexed { index, item ->
Column(
Modifier
.fillMaxWidth()
.container(
shape = ShapeDefaults.byIndex(
index = index,
size = values.size
),
resultPadding = 0.dp
)
) {
TitleItem(
text = stringResource(item.getTitle()),
icon = item.getIcon()
)
Text(
text = stringResource(item.getSubtitle()),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp
),
fontSize = 14.sp,
lineHeight = 18.sp
)
}
}
}
},
visible = isSheetVisible,
onDismiss = {
isSheetVisible = it
},
title = {
TitleItem(text = stringResource(R.string.draw_mode))
},
confirmButton = {
EnhancedButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
onClick = { isSheetVisible = false }
) {
AutoSizeText(stringResource(R.string.close))
}
}
)
}
private fun DrawLineStyle.getSubtitle(): Int = when (this) {
is DrawLineStyle.Dashed -> R.string.dashed_sub
DrawLineStyle.DotDashed -> R.string.dot_dashed_sub
DrawLineStyle.None -> R.string.defaultt_sub
is DrawLineStyle.Stamped<*> -> R.string.stamped_sub
is DrawLineStyle.ZigZag -> R.string.zigzag_sub
}
private fun DrawLineStyle.getTitle(): Int = when (this) {
is DrawLineStyle.Dashed -> R.string.dashed
DrawLineStyle.DotDashed -> R.string.dot_dashed
DrawLineStyle.None -> R.string.defaultt
is DrawLineStyle.Stamped<*> -> R.string.stamped
is DrawLineStyle.ZigZag -> R.string.zigzag
}
private fun DrawLineStyle.getIcon(): ImageVector = when (this) {
is DrawLineStyle.Dashed -> Icons.Rounded.DashedLine
DrawLineStyle.DotDashed -> Icons.Rounded.DotDashedLine
DrawLineStyle.None -> Icons.Rounded.BoldLine
is DrawLineStyle.Stamped<*> -> Icons.Rounded.StampedLine
is DrawLineStyle.ZigZag -> Icons.Rounded.ZigzagLine
}
@@ -0,0 +1,261 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterTemplateCreationSheetComponent
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheetComponent
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.AutoFixHigh
import com.t8rin.imagetoolbox.core.resources.icons.BlurCircular
import com.t8rin.imagetoolbox.core.resources.icons.Cube
import com.t8rin.imagetoolbox.core.resources.icons.Healing
import com.t8rin.imagetoolbox.core.resources.icons.Highlighter
import com.t8rin.imagetoolbox.core.resources.icons.Image
import com.t8rin.imagetoolbox.core.resources.icons.MeshGradient
import com.t8rin.imagetoolbox.core.resources.icons.NeonBrush
import com.t8rin.imagetoolbox.core.resources.icons.Pen
import com.t8rin.imagetoolbox.core.resources.icons.TextFormat
import com.t8rin.imagetoolbox.core.ui.widget.buttons.SupportingButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.CustomPathEffectParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.ImageParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.PixelationParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.PrivacyBlurParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.SpotHealParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.TextParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.WarpParamsSelector
@Composable
fun DrawModeSelector(
addFiltersSheetComponent: AddFiltersSheetComponent,
filterTemplateCreationSheetComponent: FilterTemplateCreationSheetComponent,
modifier: Modifier,
value: DrawMode,
strokeWidth: Pt,
onValueChange: (DrawMode) -> Unit,
values: List<DrawMode> = DrawMode.entries
) {
var isSheetVisible by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(values, value) {
if (values.find { it::class.isInstance(value) } == null) {
values.firstOrNull()?.let { onValueChange(it) }
}
}
Column(
modifier = modifier
.container(ShapeDefaults.extraLarge),
horizontalAlignment = Alignment.CenterHorizontally
) {
EnhancedButtonGroup(
enabled = true,
itemCount = values.size,
title = {
Text(
text = stringResource(R.string.draw_mode),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.width(8.dp))
SupportingButton(
onClick = {
isSheetVisible = true
}
)
},
selectedIndex = values.indexOfFirst {
value::class.isInstance(it)
},
itemContent = {
Icon(
imageVector = values[it].getIcon(),
contentDescription = null
)
},
onIndexChange = {
onValueChange(values[it])
}
)
WarpParamsSelector(
value = value,
onValueChange = onValueChange
)
SpotHealParamsSelector(
value = value,
onValueChange = onValueChange
)
PrivacyBlurParamsSelector(
value = value,
onValueChange = onValueChange
)
PixelationParamsSelector(
value = value,
onValueChange = onValueChange
)
TextParamsSelector(
value = value,
onValueChange = onValueChange
)
ImageParamsSelector(
value = value,
onValueChange = onValueChange,
strokeWidth = strokeWidth
)
CustomPathEffectParamsSelector(
value = value,
onValueChange = onValueChange,
addFiltersSheetComponent = addFiltersSheetComponent,
filterTemplateCreationSheetComponent = filterTemplateCreationSheetComponent
)
}
EnhancedModalBottomSheet(
sheetContent = {
Column(
modifier = Modifier
.enhancedVerticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
values.forEachIndexed { index, item ->
Column(
Modifier
.fillMaxWidth()
.container(
shape = ShapeDefaults.byIndex(
index = index,
size = values.size
),
resultPadding = 0.dp
)
) {
TitleItem(
text = stringResource(item.getTitle()),
icon = item.getIcon()
)
Text(
text = stringResource(item.getSubtitle()),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp
),
fontSize = 14.sp,
lineHeight = 18.sp
)
}
}
}
},
visible = isSheetVisible,
onDismiss = {
isSheetVisible = it
},
title = {
TitleItem(text = stringResource(R.string.draw_mode))
},
confirmButton = {
EnhancedButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
onClick = { isSheetVisible = false }
) {
AutoSizeText(stringResource(R.string.close))
}
}
)
}
private fun DrawMode.getSubtitle(): Int = when (this) {
is DrawMode.Highlighter -> R.string.highlighter_sub
is DrawMode.Neon -> R.string.neon_sub
is DrawMode.Pen -> R.string.pen_sub
is DrawMode.PathEffect.PrivacyBlur -> R.string.privacy_blur_sub
is DrawMode.PathEffect.Pixelation -> R.string.pixelation_sub
is DrawMode.Text -> R.string.draw_text_sub
is DrawMode.Image -> R.string.draw_mode_image_sub
is DrawMode.PathEffect.Custom -> R.string.draw_filter_sub
is DrawMode.SpotHeal -> R.string.spot_heal_sub
is DrawMode.Warp -> R.string.warp_sub
}
private fun DrawMode.getTitle(): Int = when (this) {
is DrawMode.Highlighter -> R.string.highlighter
is DrawMode.Neon -> R.string.neon
is DrawMode.Pen -> R.string.pen
is DrawMode.PathEffect.PrivacyBlur -> R.string.privacy_blur
is DrawMode.PathEffect.Pixelation -> R.string.pixelation
is DrawMode.Text -> R.string.text
is DrawMode.Image -> R.string.image
is DrawMode.PathEffect.Custom -> R.string.filter
is DrawMode.SpotHeal -> R.string.spot_heal
is DrawMode.Warp -> R.string.warp
}
private fun DrawMode.getIcon(): ImageVector = when (this) {
is DrawMode.Highlighter -> Icons.Outlined.Highlighter
is DrawMode.Neon -> Icons.Outlined.NeonBrush
is DrawMode.Pen -> Icons.Outlined.Pen
is DrawMode.PathEffect.PrivacyBlur -> Icons.Outlined.BlurCircular
is DrawMode.PathEffect.Pixelation -> Icons.Outlined.Cube
is DrawMode.Text -> Icons.Rounded.TextFormat
is DrawMode.Image -> Icons.Outlined.Image
is DrawMode.PathEffect.Custom -> Icons.Outlined.AutoFixHigh
is DrawMode.SpotHeal -> Icons.Outlined.Healing
is DrawMode.Warp -> Icons.Outlined.MeshGradient
}
@@ -0,0 +1,174 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.buttons.SupportingButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.ArrowParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.DrawPathModeInfoSheet
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.FloodFillParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.OvalParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.PolygonParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.RectParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.SprayParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.StarParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.element.TriangleParamsSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getIcon
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.saveState
@Composable
fun DrawPathModeSelector(
modifier: Modifier,
values: List<DrawPathMode> = DrawPathMode.entries,
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit,
drawMode: DrawMode,
containerColor: Color = Color.Unspecified
) {
var isSheetVisible by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(value, values) {
if (values.find { it::class.isInstance(value) } == null) {
values.firstOrNull()?.let { onValueChange(it) }
}
}
Column(
modifier = modifier
.container(
shape = ShapeDefaults.extraLarge,
color = containerColor
),
horizontalAlignment = Alignment.CenterHorizontally
) {
EnhancedButtonGroup(
enabled = true,
itemCount = values.size,
title = {
Text(
text = stringResource(R.string.draw_path_mode),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.width(8.dp))
SupportingButton(
onClick = {
isSheetVisible = true
}
)
},
selectedIndex = remember(values, value) {
derivedStateOf {
values.indexOfFirst {
value::class.isInstance(it)
}
}
}.value,
activeButtonColor = MaterialTheme.colorScheme.surfaceContainerHighest,
itemContent = {
Icon(
imageVector = values[it].getIcon(),
contentDescription = null
)
},
onIndexChange = {
onValueChange(values[it].saveState(value))
}
)
val canChangeFillColor =
value is DrawPathMode.Outlined && (drawMode is DrawMode.Pen || drawMode is DrawMode.Highlighter || drawMode is DrawMode.Neon)
PolygonParamsSelector(
value = value,
onValueChange = onValueChange,
canChangeFillColor = canChangeFillColor
)
StarParamsSelector(
value = value,
onValueChange = onValueChange,
canChangeFillColor = canChangeFillColor
)
RectParamsSelector(
value = value,
onValueChange = onValueChange,
canChangeFillColor = canChangeFillColor
)
OvalParamsSelector(
value = value,
onValueChange = onValueChange,
canChangeFillColor = canChangeFillColor
)
TriangleParamsSelector(
value = value,
onValueChange = onValueChange,
canChangeFillColor = canChangeFillColor
)
ArrowParamsSelector(
value = value,
onValueChange = onValueChange
)
FloodFillParamsSelector(
value = value,
onValueChange = onValueChange
)
SprayParamsSelector(
value = value,
onValueChange = onValueChange
)
}
DrawPathModeInfoSheet(
visible = isSheetVisible,
onDismiss = { isSheetVisible = false },
values = values
)
}
@@ -0,0 +1,60 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.LineWeight
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
@Composable
fun LineWidthSelector(
modifier: Modifier,
value: Float,
title: String = stringResource(R.string.line_width),
valueRange: ClosedFloatingPointRange<Float> = 1f..100f,
color: Color = Color.Unspecified,
onValueChange: (Float) -> Unit
) {
EnhancedSliderItem(
modifier = modifier,
value = value,
containerColor = color,
icon = Icons.Rounded.LineWeight,
title = title,
valueSuffix = " Pt",
sliderModifier = Modifier
.padding(top = 14.dp, start = 12.dp, end = 12.dp, bottom = 10.dp),
valueRange = valueRange,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(it.roundToTwoDigits())
},
shape = ShapeDefaults.extraLarge
)
}
@@ -0,0 +1,67 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Eyedropper
import com.t8rin.imagetoolbox.core.ui.theme.mixedContainer
import com.t8rin.imagetoolbox.core.ui.theme.onMixedContainer
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.hapticsClickable
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
@Composable
fun OpenColorPickerCard(
onOpen: () -> Unit,
modifier: Modifier = Modifier
.padding(horizontal = 16.dp)
) {
Row(
modifier = modifier
.container(
resultPadding = 0.dp,
color = MaterialTheme.colorScheme.mixedContainer.copy(0.7f),
shape = ShapeDefaults.extraLarge
)
.hapticsClickable(onClick = onOpen)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.pipette),
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onMixedContainer
)
Icon(
imageVector = Icons.Outlined.Eyedropper,
contentDescription = stringResource(R.string.pipette),
tint = MaterialTheme.colorScheme.onMixedContainer
)
}
}
@@ -0,0 +1,62 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Cube
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
@Composable
fun PixelSizeSelector(
modifier: Modifier,
value: Float,
onValueChange: (Float) -> Unit,
color: Color = Color.Unspecified
) {
EnhancedSliderItem(
modifier = modifier,
value = value,
title = stringResource(R.string.pixel_size),
sliderModifier = Modifier
.padding(
top = 14.dp,
start = 12.dp,
end = 12.dp,
bottom = 10.dp
),
icon = Icons.Outlined.Cube,
valueRange = 10f..75f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(it.roundToTwoDigits())
},
shape = ShapeDefaults.default,
containerColor = color
)
}
@@ -0,0 +1,52 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.domain.PathPaint
data class UiPathPaint(
override val path: Path,
override val strokeWidth: Pt,
override val brushSoftness: Pt,
override val drawColor: Color = Color.Transparent,
override val isErasing: Boolean,
override val drawMode: DrawMode = DrawMode.Pen,
override val canvasSize: IntegerSize,
override val drawPathMode: DrawPathMode = DrawPathMode.Free,
override val drawLineStyle: DrawLineStyle = DrawLineStyle.None
) : PathPaint<Path, Color>
fun PathPaint<Path, Color>.toUiPathPaint() = UiPathPaint(
path = path,
strokeWidth = strokeWidth,
brushSoftness = brushSoftness,
drawColor = drawColor,
isErasing = isErasing,
drawMode = drawMode,
canvasSize = canvasSize,
drawPathMode = drawPathMode,
drawLineStyle = drawLineStyle
)
@@ -0,0 +1,395 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components
import android.annotation.SuppressLint
import android.graphics.Bitmap
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePaint
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.nativePaint
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.createBitmap
import com.t8rin.imagetoolbox.core.domain.model.ImageModel
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.filters.domain.model.createFilter
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
import com.t8rin.imagetoolbox.core.ui.utils.helper.scaleToFitCanvas
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.LoadingDialog
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
import com.t8rin.imagetoolbox.core.utils.toImageModel
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.clipBitmap
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedImageOnPath
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.drawRepeatedTextOnPath
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.overlay
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.pathEffectPaint
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rememberPaint
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.transformationsForMode
import com.t8rin.trickle.WarpBrush
import com.t8rin.trickle.WarpEngine
import com.t8rin.trickle.WarpMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
import android.graphics.Paint as AndroidPaint
@SuppressLint("ComposableNaming")
@Composable
internal fun Canvas.UiPathPaintCanvasAction(
uiPathPaint: UiPathPaint,
invalidations: Int,
onInvalidate: () -> Unit,
canvasSize: IntegerSize,
pathsCount: Int,
backgroundColor: Color,
drawImageBitmap: ImageBitmap,
drawBitmap: ImageBitmap,
onClearDrawPath: () -> Unit,
onClearWarpDrawPath: (Long) -> Unit,
onRequestFiltering: suspend (Bitmap, List<Filter<*>>) -> Bitmap?,
spotHealCache: Map<Int, Bitmap>,
onCacheSpotHealPathResult: (Int, Bitmap) -> Unit,
onCancel: (UiPathPaint) -> Unit,
scope: CoroutineScope
) = with(nativeCanvas) {
val (nonScaledPath, strokeWidth, brushSoftness, drawColor, isEraserOn, drawMode, size, drawPathMode, drawLineStyle) = uiPathPaint
val path by remember(nonScaledPath, canvasSize, size) {
derivedStateOf {
nonScaledPath.scaleToFitCanvas(
currentSize = canvasSize,
oldSize = size
).asAndroidPath()
}
}
if (drawMode is DrawMode.PathEffect && !isEraserOn) {
var shaderSource by remember(backgroundColor) {
mutableStateOf<ImageBitmap?>(null)
}
LaunchedEffect(shaderSource, invalidations) {
withContext(Dispatchers.Default) {
if (shaderSource == null || invalidations <= pathsCount) {
shaderSource = onRequestFiltering(
drawImageBitmap.overlay(drawBitmap)
.asAndroidBitmap(),
transformationsForMode(
drawMode = drawMode,
canvasSize = canvasSize
)
)?.asImageBitmap()?.clipBitmap(
path = path.asComposePath(),
paint = pathEffectPaint(
strokeWidth = strokeWidth,
drawPathMode = drawPathMode,
canvasSize = canvasSize
).asComposePaint()
)?.also {
it.prepareToDraw()
onInvalidate()
}
}
}
}
if (shaderSource != null) {
LaunchedEffect(shaderSource) {
onClearDrawPath()
}
val imagePaint = remember { Paint() }
drawImage(
image = shaderSource!!,
topLeftOffset = Offset.Zero,
paint = imagePaint
)
}
} else if (drawMode is DrawMode.SpotHeal && !isEraserOn) {
val cacheKey = uiPathPaint.hashCode()
val paint = remember(uiPathPaint, canvasSize) {
val isSharpEdge = drawPathMode.isSharpEdge
val isFilled = drawPathMode.isFilled
val stroke = strokeWidth.toPx(canvasSize)
Paint().apply {
if (isFilled) {
style = PaintingStyle.Fill
} else {
style = PaintingStyle.Stroke
this.strokeWidth = stroke
if (isSharpEdge) {
strokeCap = StrokeCap.Square
} else {
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
}
}
color = Color.White
}
}
var isLoading by remember {
mutableStateOf(false)
}
var progress by remember {
mutableFloatStateOf(0f)
}
var shaderSource by remember(cacheKey, backgroundColor) {
mutableStateOf(spotHealCache[cacheKey]?.asImageBitmap())
}
LaunchedEffect(shaderSource, invalidations) {
withContext(Dispatchers.Default) {
if (shaderSource == null) {
isLoading = true
val job = launch {
while (progress < 0.5f && isActive && isLoading) {
progress += 0.01f
delay(100)
}
while (progress < 0.75f && isActive && isLoading) {
progress += 0.0025f
delay(100)
}
while (progress < 1f && isActive && isLoading) {
progress += 0.0025f
delay(500)
}
}
shaderSource = withContext(Dispatchers.IO) {
onRequestFiltering(
drawImageBitmap.overlay(drawBitmap).asAndroidBitmap(),
listOf(
createFilter<Pair<ImageModel, SpotHealMode>, Filter.SpotHeal>(
Pair(
createBitmap(
width = canvasSize.width,
height = canvasSize.height
).applyCanvas {
drawColor(Color.Black.toArgb())
drawPath(
path,
paint.nativePaint
)
}.toImageModel(),
drawMode.mode
)
)
)
)?.asImageBitmap()?.clipBitmap(
path = path.asComposePath(),
paint = paint.apply {
blendMode = BlendMode.Clear
}
)?.also {
onCacheSpotHealPathResult(cacheKey, it.asAndroidBitmap())
it.prepareToDraw()
onInvalidate()
}
}
isLoading = false
job.cancel()
progress = 0f
}
}
}
if (shaderSource != null) {
LaunchedEffect(shaderSource) {
onClearDrawPath()
onInvalidate()
}
val imagePaint = remember { Paint() }
drawImageRect(
image = shaderSource!!,
dstSize = IntSize(canvasSize.width, canvasSize.height),
paint = imagePaint
)
}
LoadingDialog(
visible = isLoading,
onCancelLoading = {
scope.launch {
isLoading = false
delay(400)
onClearDrawPath()
onCancel(uiPathPaint)
onInvalidate()
}
},
canCancel = true,
progress = { progress },
loaderSize = 72.dp,
additionalContent = {
AutoSizeText(
text = "${(progress * 100).roundToInt()}%",
maxLines = 1,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(it * 0.8f),
textAlign = TextAlign.Center
)
}
)
} else if (drawMode is DrawMode.Warp && !isEraserOn) {
var warpedBitmap by remember(uiPathPaint, canvasSize) {
mutableStateOf<ImageBitmap?>(null)
}
LaunchedEffect(warpedBitmap, invalidations) {
if (warpedBitmap == null || invalidations <= pathsCount) {
val source = drawImageBitmap
.overlay(drawBitmap)
.asAndroidBitmap()
withContext(Dispatchers.Default) {
val engine = WarpEngine(source)
try {
drawMode.strokes.forEach { warp ->
val stroke = warp.scaleToFitCanvas(
currentSize = canvasSize,
oldSize = size
)
engine.applyStroke(
fromX = stroke.fromX,
fromY = stroke.fromY,
toX = stroke.toX,
toY = stroke.toY,
brush = WarpBrush(
radius = strokeWidth.toPx(canvasSize),
strength = drawMode.strength,
hardness = drawMode.hardness
),
mode = WarpMode.valueOf(drawMode.warpMode.name)
)
}
warpedBitmap = engine
.render()
.asImageBitmap().also {
it.prepareToDraw()
onInvalidate()
}
} finally {
engine.release()
}
}
}
}
warpedBitmap?.let {
LaunchedEffect(warpedBitmap) {
onClearWarpDrawPath(drawMode.previewClearToken)
}
val imagePaint = remember { Paint() }
drawImage(
image = warpedBitmap!!,
topLeftOffset = Offset.Zero,
paint = imagePaint
)
}
} else {
val pathPaint by rememberPaint(
strokeWidth = strokeWidth,
isEraserOn = isEraserOn,
drawColor = drawColor,
brushSoftness = brushSoftness,
drawMode = drawMode,
canvasSize = canvasSize,
drawPathMode = drawPathMode,
drawLineStyle = drawLineStyle
)
if (drawMode is DrawMode.Text && !isEraserOn) {
if (drawMode.isRepeated) {
drawRepeatedTextOnPath(
text = drawMode.text,
path = path,
paint = pathPaint,
interval = drawMode.repeatingInterval.toPx(canvasSize)
)
} else {
drawTextOnPath(drawMode.text, path, 0f, 0f, pathPaint)
}
} else if (drawMode is DrawMode.Image && !isEraserOn) {
drawRepeatedImageOnPath(
drawMode = drawMode,
strokeWidth = strokeWidth,
canvasSize = canvasSize,
path = path,
paint = pathPaint,
invalidations = invalidations
)
} else if (drawPathMode is DrawPathMode.Outlined) {
drawPathMode.fillColor?.let { fillColor ->
val filledPaint = remember(fillColor, pathPaint) {
AndroidPaint().apply {
set(pathPaint)
style = AndroidPaint.Style.FILL
color = fillColor.colorInt
if (Color(fillColor.colorInt).alpha == 1f) {
alpha =
(drawColor.alpha * 255).roundToInt().coerceIn(0, 255)
}
pathEffect = null
}
}
drawPath(path, filledPaint)
}
drawPath(path, pathPaint)
} else {
drawPath(path, pathPaint)
}
}
}
@@ -0,0 +1,310 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.controls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.domain.model.pt
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.BackgroundColor
import com.t8rin.imagetoolbox.core.ui.utils.helper.isPortraitOrientationAsState
import com.t8rin.imagetoolbox.core.ui.widget.controls.SaveExifWidget
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.AlphaSelector
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ColorRowSelector
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.HelperGridParamsSelector
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ImageFormatSelector
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.MagnifierEnabledSelector
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.saver.ColorSaver
import com.t8rin.imagetoolbox.feature.draw.domain.DrawBehavior
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.BrushSoftnessSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawColorSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawLineAngleSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawLineStyleSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawModeSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.DrawPathModeSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.LineWidthSelector
import com.t8rin.imagetoolbox.feature.draw.presentation.components.OpenColorPickerCard
import com.t8rin.imagetoolbox.feature.draw.presentation.components.canShowLineAngle
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
import com.t8rin.imagetoolbox.feature.pick_color.presentation.components.PickColorFromImageSheet
@Composable
internal fun DrawContentControls(
component: DrawComponent,
secondaryControls: @Composable () -> Unit,
drawColor: Color,
onDrawColorChange: (Color) -> Unit,
strokeWidth: Pt,
onStrokeWidthChange: (Pt) -> Unit,
brushSoftness: Pt,
onBrushSoftnessChange: (Pt) -> Unit,
alpha: Float,
onAlphaChange: (Float) -> Unit,
showLineAngle: Boolean,
onShowLineAngleChange: (Boolean) -> Unit
) {
var showPickColorSheet by rememberSaveable { mutableStateOf(false) }
val isPortrait by isPortraitOrientationAsState()
val drawMode = component.drawMode
val drawPathMode = component.drawPathMode
val drawLineStyle = component.drawLineStyle
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!isPortrait) {
Row(
modifier = Modifier
.padding(vertical = 8.dp)
.container(shape = ShapeDefaults.circle)
) {
secondaryControls()
}
}
AnimatedVisibility(
visible = drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
OpenColorPickerCard(
modifier = Modifier.fillMaxWidth(),
onOpen = {
component.openColorPicker()
showPickColorSheet = true
}
)
}
AnimatedVisibility(
visible = drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.Image && drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
DrawColorSelector(
modifier = Modifier.fillMaxWidth(),
value = drawColor,
onValueChange = onDrawColorChange
)
}
AnimatedVisibility(
visible = drawPathMode.canChangeStrokeWidth,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
LineWidthSelector(
modifier = Modifier.fillMaxWidth(),
title = if (drawMode is DrawMode.Text) {
stringResource(R.string.font_size)
} else stringResource(R.string.line_width),
valueRange = if (drawMode is DrawMode.Image) {
10f..120f
} else 1f..100f,
value = strokeWidth.value,
onValueChange = { onStrokeWidthChange(it.pt) }
)
}
AnimatedVisibility(
visible = drawMode !is DrawMode.Highlighter && drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
BrushSoftnessSelector(
modifier = Modifier.fillMaxWidth(),
value = brushSoftness.value,
onValueChange = { onBrushSoftnessChange(it.pt) }
)
}
if (component.drawBehavior is DrawBehavior.Background) {
ColorRowSelector(
value = component.backgroundColor,
onValueChange = component::updateBackgroundColor,
icon = Icons.Outlined.BackgroundColor,
modifier = Modifier
.fillMaxWidth()
.container(
shape = ShapeDefaults.extraLarge
)
)
}
AnimatedVisibility(
visible = drawMode !is DrawMode.Neon && drawMode !is DrawMode.PathEffect && drawMode !is DrawMode.SpotHeal && drawMode !is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
AlphaSelector(
value = alpha,
onValueChange = onAlphaChange,
modifier = Modifier.fillMaxWidth()
)
}
DrawModeSelector(
modifier = Modifier.fillMaxWidth(),
value = drawMode,
strokeWidth = strokeWidth,
onValueChange = component::updateDrawMode,
values = remember(drawLineStyle) {
derivedStateOf {
if (drawLineStyle == DrawLineStyle.None) {
DrawMode.entries
} else {
listOf(
DrawMode.Pen,
DrawMode.Highlighter,
DrawMode.Neon
)
}
}
}.value,
addFiltersSheetComponent = component.addFiltersSheetComponent,
filterTemplateCreationSheetComponent = component.filterTemplateCreationSheetComponent
)
AnimatedVisibility(
visible = drawMode !is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
DrawPathModeSelector(
modifier = Modifier.fillMaxWidth(),
value = drawPathMode,
onValueChange = component::updateDrawPathMode,
values = remember(drawMode, drawLineStyle) {
derivedStateOf {
if (drawMode !is DrawMode.Text && drawMode !is DrawMode.Image) {
when (drawLineStyle) {
DrawLineStyle.None -> DrawPathMode.entries
!is DrawLineStyle.Stamped<*> -> listOf(
DrawPathMode.Free,
DrawPathMode.Line,
DrawPathMode.LinePointingArrow(),
DrawPathMode.PointingArrow(),
DrawPathMode.DoublePointingArrow(),
DrawPathMode.DoubleLinePointingArrow(),
) + DrawPathMode.outlinedEntries
else -> listOf(
DrawPathMode.Free,
DrawPathMode.Line
) + DrawPathMode.outlinedEntries
}
} else {
listOf(
DrawPathMode.Free,
DrawPathMode.Line
) + DrawPathMode.outlinedEntries
}
}
}.value,
drawMode = drawMode
)
}
AnimatedVisibility(
visible = drawPathMode.canShowLineAngle() && drawMode !is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
DrawLineAngleSelector(
modifier = Modifier.fillMaxWidth(),
checked = showLineAngle,
onCheckedChange = onShowLineAngleChange
)
}
AnimatedVisibility(
visible = drawMode !is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.fillMaxWidth()
) {
DrawLineStyleSelector(
modifier = Modifier.fillMaxWidth(),
value = drawLineStyle,
onValueChange = component::updateDrawLineStyle
)
}
HelperGridParamsSelector(
value = component.helperGridParams,
onValueChange = component::updateHelperGridParams,
modifier = Modifier.fillMaxWidth()
)
MagnifierEnabledSelector(
modifier = Modifier.fillMaxWidth(),
shape = ShapeDefaults.extraLarge,
)
SaveExifWidget(
modifier = Modifier.fillMaxWidth(),
checked = component.saveExif,
imageFormat = component.imageFormat,
onCheckedChange = component::setSaveExif
)
ImageFormatSelector(
modifier = Modifier.navigationBarsPadding(),
forceEnabled = component.drawBehavior is DrawBehavior.Background,
value = component.imageFormat,
onValueChange = component::setImageFormat
)
}
var colorPickerColor by rememberSaveable(stateSaver = ColorSaver) { mutableStateOf(Color.Black) }
PickColorFromImageSheet(
visible = showPickColorSheet,
onDismiss = {
showPickColorSheet = false
},
bitmap = component.colorPickerBitmap,
onColorChange = { colorPickerColor = it },
color = colorPickerColor
)
}
@@ -0,0 +1,246 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.controls
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.BackgroundColor
import com.t8rin.imagetoolbox.core.resources.icons.ImagesMode
import com.t8rin.imagetoolbox.core.resources.icons.ImagesearchRoller
import com.t8rin.imagetoolbox.core.ui.theme.toColor
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.restrict
import com.t8rin.imagetoolbox.core.ui.utils.provider.LocalScreenSize
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ColorRowSelector
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedFlingBehavior
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceItem
import com.t8rin.imagetoolbox.core.ui.widget.saver.ColorSaver
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextField
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
@Composable
internal fun DrawContentNoDataControls(
component: DrawComponent,
onPickImage: () -> Unit
) {
var showBackgroundDrawingSetup by rememberSaveable { mutableStateOf(false) }
val cutout = WindowInsets.displayCutout.asPaddingValues()
LazyVerticalStaggeredGrid(
modifier = Modifier.fillMaxHeight(),
columns = StaggeredGridCells.Adaptive(300.dp),
horizontalArrangement = Arrangement.spacedBy(
space = 12.dp,
alignment = Alignment.CenterHorizontally
),
verticalItemSpacing = 12.dp,
contentPadding = PaddingValues(
bottom = 12.dp + WindowInsets
.navigationBars
.asPaddingValues()
.calculateBottomPadding(),
top = 12.dp,
end = 12.dp + cutout.calculateEndPadding(
LocalLayoutDirection.current
),
start = 12.dp + cutout.calculateStartPadding(
LocalLayoutDirection.current
)
),
flingBehavior = enhancedFlingBehavior()
) {
item {
PreferenceItem(
onClick = onPickImage,
startIcon = Icons.Outlined.ImagesMode,
title = stringResource(R.string.draw_on_image),
subtitle = stringResource(R.string.draw_on_image_sub),
modifier = Modifier.fillMaxWidth()
)
}
item {
PreferenceItem(
onClick = { showBackgroundDrawingSetup = true },
startIcon = Icons.Outlined.ImagesearchRoller,
title = stringResource(R.string.draw_on_background),
subtitle = stringResource(R.string.draw_on_background_sub),
modifier = Modifier.fillMaxWidth()
)
}
}
val drawOnBackgroundParams = component.drawOnBackgroundParams
val screenSize = LocalScreenSize.current
val screenWidth = screenSize.widthPx
val screenHeight = screenSize.heightPx
var width by remember(
showBackgroundDrawingSetup,
screenWidth,
drawOnBackgroundParams
) {
mutableIntStateOf(drawOnBackgroundParams.width.takeIf { it > 0 } ?: screenWidth)
}
var height by remember(
showBackgroundDrawingSetup,
screenHeight,
drawOnBackgroundParams
) {
mutableIntStateOf(drawOnBackgroundParams.height.takeIf { it > 0 } ?: screenHeight)
}
var sheetBackgroundColor by rememberSaveable(
showBackgroundDrawingSetup,
drawOnBackgroundParams,
stateSaver = ColorSaver
) {
mutableStateOf(drawOnBackgroundParams.color?.toColor() ?: Color.White)
}
EnhancedModalBottomSheet(
title = {
TitleItem(
text = stringResource(R.string.draw),
icon = Icons.Rounded.ImagesearchRoller
)
},
confirmButton = {
EnhancedButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
onClick = {
showBackgroundDrawingSetup = false
component.startDrawOnBackground(
reqWidth = width,
reqHeight = height,
color = sheetBackgroundColor
)
}
) {
AutoSizeText(stringResource(R.string.ok))
}
},
sheetContent = {
Box {
Column(Modifier.enhancedVerticalScroll(rememberScrollState())) {
Row(
Modifier
.padding(16.dp)
.container(shape = ShapeDefaults.extraLarge)
) {
RoundedTextField(
value = width.takeIf { it != 0 }?.toString() ?: "",
onValueChange = {
width = it.restrict(8192).toIntOrNull() ?: 0
},
shape = ShapeDefaults.smallStart,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
),
label = {
Text(stringResource(R.string.width, " "))
},
modifier = Modifier
.weight(1f)
.padding(
start = 8.dp,
top = 8.dp,
bottom = 8.dp,
end = 2.dp
)
)
RoundedTextField(
value = height.takeIf { it != 0 }?.toString() ?: "",
onValueChange = {
height = it.restrict(8192).toIntOrNull() ?: 0
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
),
shape = ShapeDefaults.smallEnd,
label = {
Text(stringResource(R.string.height, " "))
},
modifier = Modifier
.weight(1f)
.padding(
start = 2.dp,
top = 8.dp,
bottom = 8.dp,
end = 8.dp
),
)
}
ColorRowSelector(
value = sheetBackgroundColor,
onValueChange = { sheetBackgroundColor = it },
icon = Icons.Outlined.BackgroundColor,
modifier = Modifier
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp
)
.container(ShapeDefaults.extraLarge)
)
}
}
},
visible = showBackgroundDrawingSetup,
onDismiss = {
showBackgroundDrawingSetup = it
}
)
}
@@ -0,0 +1,65 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.controls
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import com.t8rin.imagetoolbox.core.resources.icons.Redo
import com.t8rin.imagetoolbox.core.resources.icons.Undo
import com.t8rin.imagetoolbox.core.ui.widget.buttons.EraseModeButton
import com.t8rin.imagetoolbox.core.ui.widget.buttons.PanModeButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedIconButton
import com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic.DrawComponent
@Composable
internal fun DrawContentSecondaryControls(
component: DrawComponent,
panEnabled: Boolean,
onTogglePanEnabled: () -> Unit,
isEraserOn: Boolean,
onToggleIsEraserOn: () -> Unit
) {
PanModeButton(
selected = panEnabled,
onClick = onTogglePanEnabled
)
EnhancedIconButton(
onClick = component::undo,
enabled = component.lastPaths.isNotEmpty() || component.paths.isNotEmpty()
) {
Icon(
imageVector = Icons.Rounded.Undo,
contentDescription = "Undo"
)
}
EnhancedIconButton(
onClick = component::redo,
enabled = component.undonePaths.isNotEmpty()
) {
Icon(
imageVector = Icons.Rounded.Redo,
contentDescription = "Redo"
)
}
EraseModeButton(
selected = isEraserOn,
enabled = !panEnabled,
onClick = onToggleIsEraserOn
)
}
@@ -0,0 +1,96 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.domain.utils.roundTo
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.angle
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isArrow
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.sizeScale
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateArrow
@Composable
internal fun ArrowParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit
) {
AnimatedVisibility(
visible = value.isArrow(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
EnhancedSliderItem(
value = value.sizeScale(),
title = stringResource(R.string.head_length_scale),
valueRange = 0.5f..8f,
internalStateTransformation = {
it.roundTo(1)
},
onValueChange = {
onValueChange(
value.updateArrow(sizeScale = it)
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.top
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = value.angle() - 90f,
title = stringResource(R.string.angle),
valueRange = 0f..90f,
internalStateTransformation = {
it.roundTo(1)
},
onValueChange = {
onValueChange(
value.updateArrow(angle = it + 90f)
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.bottom
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,158 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.filters.presentation.model.toUiFilter
import com.t8rin.imagetoolbox.core.filters.presentation.widget.AddFilterButton
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterItem
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterTemplateCreationSheetComponent
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheet
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheetComponent
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.AutoFixHigh
import com.t8rin.imagetoolbox.core.ui.theme.mixedContainer
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.other.InfoContainer
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
@Composable
internal fun CustomPathEffectParamsSelector(
value: DrawMode,
onValueChange: (DrawMode) -> Unit,
addFiltersSheetComponent: AddFiltersSheetComponent,
filterTemplateCreationSheetComponent: FilterTemplateCreationSheetComponent
) {
AnimatedVisibility(
visible = value is DrawMode.PathEffect.Custom,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
val filter by remember(value) {
derivedStateOf {
(value as? DrawMode.PathEffect.Custom)?.filter?.toUiFilter()
}
}
var showFilterSelection by rememberSaveable {
mutableStateOf(false)
}
AnimatedContent(
targetState = filter,
contentKey = { it != null }
) { filter ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp)
) {
if (filter != null) {
FilterItem(
filter = filter,
showDragHandle = false,
onRemove = {
onValueChange(
DrawMode.PathEffect.Custom()
)
},
onFilterChange = { value ->
onValueChange(
DrawMode.PathEffect.Custom(filter.copy(value))
)
},
backgroundColor = MaterialTheme.colorScheme.surface,
shape = ShapeDefaults.default,
canHide = false,
onCreateTemplate = null
)
Spacer(modifier = Modifier.height(8.dp))
EnhancedButton(
containerColor = MaterialTheme.colorScheme.mixedContainer,
onClick = {
showFilterSelection = true
}
) {
Icon(
imageVector = Icons.Rounded.AutoFixHigh,
contentDescription = stringResource(R.string.replace_filter)
)
Spacer(Modifier.width(8.dp))
Text(stringResource(id = R.string.replace_filter))
}
Spacer(modifier = Modifier.height(8.dp))
} else {
InfoContainer(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
text = stringResource(R.string.pick_filter_info)
)
Spacer(modifier = Modifier.height(8.dp))
AddFilterButton(
onClick = {
showFilterSelection = true
}
)
Spacer(modifier = Modifier.height(8.dp))
}
}
AddFiltersSheet(
component = addFiltersSheetComponent,
filterTemplateCreationSheetComponent = filterTemplateCreationSheetComponent,
visible = showFilterSelection,
onVisibleChange = {
showFilterSelection = it
},
canAddTemplates = false,
previewBitmap = null,
onFilterPicked = {
onValueChange(
DrawMode.PathEffect.Custom(it.newInstance())
)
},
onFilterPickedWithParams = {
onValueChange(
DrawMode.PathEffect.Custom(it)
)
}
)
}
}
}
@@ -0,0 +1,102 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getIcon
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getSubtitle
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.getTitle
@Composable
internal fun DrawPathModeInfoSheet(
visible: Boolean,
onDismiss: () -> Unit,
values: List<DrawPathMode>
) {
EnhancedModalBottomSheet(
sheetContent = {
Column(
modifier = Modifier
.enhancedVerticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
values.forEachIndexed { index, item ->
Column(
Modifier
.fillMaxWidth()
.container(
shape = ShapeDefaults.byIndex(
index = index,
size = values.size
),
resultPadding = 0.dp
)
) {
TitleItem(text = stringResource(item.getTitle()), icon = item.getIcon())
Text(
text = stringResource(item.getSubtitle()),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp
),
fontSize = 14.sp,
lineHeight = 18.sp
)
}
}
}
},
visible = visible,
onDismiss = {
if (!it) onDismiss()
},
title = {
TitleItem(text = stringResource(R.string.draw_path_mode))
},
confirmButton = {
EnhancedButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
onClick = onDismiss
) {
AutoSizeText(stringResource(R.string.close))
}
}
)
}
@@ -0,0 +1,77 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isFloodFill
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.tolerance
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateFloodFill
@Composable
internal fun FloodFillParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit
) {
AnimatedVisibility(
visible = value.isFloodFill(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
EnhancedSliderItem(
value = value.tolerance(),
title = stringResource(R.string.tolerance),
valueRange = 0f..1f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
value.updateFloodFill(tolerance = it.roundToTwoDigits())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.default
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,111 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.domain.model.coerceIn
import com.t8rin.imagetoolbox.core.domain.model.pt
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ImageSelector
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
@Composable
internal fun ImageParamsSelector(
value: DrawMode,
onValueChange: (DrawMode) -> Unit,
strokeWidth: Pt
) {
AnimatedVisibility(
visible = value is DrawMode.Image,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
ImageSelector(
modifier = Modifier
.padding(horizontal = 8.dp),
value = (value as? DrawMode.Image)?.imageData ?: "",
onValueChange = {
onValueChange(
(value as? DrawMode.Image)?.copy(
imageData = it
) ?: value
)
},
subtitle = stringResource(id = R.string.draw_image_sub),
shape = ShapeDefaults.top,
color = MaterialTheme.colorScheme.surface
)
Spacer(modifier = Modifier.height(4.dp))
val dashMinimum = -((strokeWidth.value * 0.9f) / 2).toInt().toFloat()
LaunchedEffect(dashMinimum, value) {
if (value is DrawMode.Image && value.repeatingInterval < dashMinimum.pt) {
onValueChange(
value.copy(
repeatingInterval = value.repeatingInterval.coerceIn(
dashMinimum.pt,
100.pt
)
)
)
}
}
EnhancedSliderItem(
value = (value as? DrawMode.Image)?.repeatingInterval?.value ?: 0f,
title = stringResource(R.string.dash_size),
valueRange = dashMinimum..100f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
(value as? DrawMode.Image)?.copy(
repeatingInterval = it.pt.coerceIn(dashMinimum.pt, 100.pt)
) ?: value
)
},
containerColor = MaterialTheme.colorScheme.surface,
valueSuffix = " Pt",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.bottom
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,143 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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.geometry.Offset
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.icons.RotateRight
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import kotlin.math.atan2
import kotlin.math.roundToInt
@Composable
internal fun LineAngleIndicator(
drawDownPosition: Offset,
currentDrawPosition: Offset,
imageWidth: Int,
imageHeight: Int,
isMagnifierEnabled: Boolean,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
var size by remember {
mutableStateOf(
with(density) {
IntSize(
width = 96.dp.roundToPx(),
height = 40.dp.roundToPx()
)
}
)
}
val (indicatorWidth, indicatorHeight) = size
val offset by remember(
density,
indicatorWidth,
indicatorHeight,
currentDrawPosition,
imageWidth
) {
derivedStateOf {
with(density) {
val verticalGap = 108.dp.roundToPx()
val horizontalGap = -indicatorWidth / 2
val verticalGapSafe = if (isMagnifierEnabled) {
verticalGap - indicatorHeight
} else {
-verticalGap
}
IntOffset(
x = (currentDrawPosition.x.roundToInt() + horizontalGap)
.coerceIn(0, (imageWidth - indicatorWidth).coerceAtLeast(0)),
y = (currentDrawPosition.y.roundToInt() + verticalGapSafe)
.coerceIn(0, (imageHeight - indicatorHeight).coerceAtLeast(0))
)
}
}
}
Surface(
modifier = modifier
.offset { offset }
.defaultMinSize(minHeight = 40.dp)
.onSizeChanged { size = it },
shape = ShapeDefaults.extraLarge,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
tonalElevation = 6.dp,
shadowElevation = 2.dp
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Rounded.RotateRight,
contentDescription = null,
modifier = Modifier.padding(end = 6.dp)
)
val degrees by remember(drawDownPosition, currentDrawPosition) {
derivedStateOf {
drawDownPosition.angleDegreesTo(currentDrawPosition)
}
}
Text(
text = "${degrees}°",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
private fun Offset.angleDegreesTo(other: Offset): Int {
val degrees = Math.toDegrees(
atan2(
y = (other.y - y).toDouble(),
x = (other.x - x).toDouble()
)
).roundToInt()
return (degrees % FULL_CIRCLE_DEGREES + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
}
private const val FULL_CIRCLE_DEGREES = 360
@@ -0,0 +1,55 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.foundation.layout.fillMaxWidth
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.FormatColorFill
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ColorRowSelector
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
@Composable
fun OutlinedFillColorSelector(
value: Color?,
onValueChange: (Color?) -> Unit,
modifier: Modifier = Modifier,
shape: Shape = ShapeDefaults.default,
containerColor: Color = Color.Unspecified
) {
ColorRowSelector(
value = value,
onValueChange = onValueChange,
onNullClick = { onValueChange(null) },
title = stringResource(R.string.fill_color),
icon = Icons.Rounded.FormatColorFill,
allowAlpha = true,
modifier = modifier
.fillMaxWidth()
.container(
color = containerColor,
shape = shape
)
)
}
@@ -0,0 +1,65 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
@Composable
internal fun OvalParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit,
canChangeFillColor: Boolean
) {
AnimatedVisibility(
visible = value is DrawPathMode.OutlinedOval && canChangeFillColor,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
OutlinedFillColorSelector(
value = value.outlinedFillColor?.toColor(),
onValueChange = {
onValueChange(value.updateOutlined(it))
},
shape = ShapeDefaults.default,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
containerColor = MaterialTheme.colorScheme.surface,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,52 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.PixelSizeSelector
@Composable
internal fun PixelationParamsSelector(
value: DrawMode,
onValueChange: (DrawMode) -> Unit
) {
AnimatedVisibility(
visible = value is DrawMode.PathEffect.Pixelation,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
PixelSizeSelector(
modifier = Modifier.padding(8.dp),
value = (value as? DrawMode.PathEffect.Pixelation)?.pixelSize ?: 0f,
onValueChange = {
onValueChange(DrawMode.PathEffect.Pixelation(it))
},
color = MaterialTheme.colorScheme.surface
)
}
}
@@ -0,0 +1,139 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.SquareFoot
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isPolygon
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isRegular
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rotationDegrees
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updatePolygon
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.vertices
import kotlin.math.roundToInt
@Composable
internal fun PolygonParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit,
canChangeFillColor: Boolean
) {
AnimatedVisibility(
visible = value.isPolygon(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
AnimatedVisibility(
visible = canChangeFillColor,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
OutlinedFillColorSelector(
value = value.outlinedFillColor?.toColor(),
onValueChange = {
onValueChange(value.updateOutlined(it))
},
shape = ShapeDefaults.top,
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth()
.padding(horizontal = 8.dp),
containerColor = MaterialTheme.colorScheme.surface,
)
}
EnhancedSliderItem(
value = value.vertices(),
title = stringResource(R.string.vertices),
valueRange = 3f..24f,
steps = 20,
internalStateTransformation = {
it.roundToInt()
},
onValueChange = {
onValueChange(
value.updatePolygon(vertices = it.toInt())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = if (canChangeFillColor) ShapeDefaults.center else ShapeDefaults.top
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = value.rotationDegrees(),
title = stringResource(R.string.angle),
valueRange = 0f..360f,
internalStateTransformation = {
it.roundToInt()
},
onValueChange = {
onValueChange(
value.updatePolygon(rotationDegrees = it.toInt())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.center
)
Spacer(modifier = Modifier.height(4.dp))
PreferenceRowSwitch(
title = stringResource(R.string.draw_regular_polygon),
subtitle = stringResource(R.string.draw_regular_polygon_sub),
checked = value.isRegular(),
onClick = {
onValueChange(
value.updatePolygon(isRegular = it)
)
},
containerColor = MaterialTheme.colorScheme.surface,
shape = ShapeDefaults.bottom,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
startIcon = Icons.Rounded.SquareFoot,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,53 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.ui.widget.controls.resize_group.components.BlurRadiusSelector
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
@Composable
internal fun PrivacyBlurParamsSelector(
value: DrawMode,
onValueChange: (DrawMode) -> Unit
) {
AnimatedVisibility(
visible = value is DrawMode.PathEffect.PrivacyBlur,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
BlurRadiusSelector(
modifier = Modifier.padding(8.dp),
value = (value as? DrawMode.PathEffect.PrivacyBlur)?.blurRadius ?: 0,
valueRange = 5f..50f,
onValueChange = {
onValueChange(DrawMode.PathEffect.PrivacyBlur(it))
},
color = MaterialTheme.colorScheme.surface
)
}
}
@@ -0,0 +1,116 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.cornerRadius
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isRect
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rotationDegrees
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateRect
import kotlin.math.roundToInt
@Composable
internal fun RectParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit,
canChangeFillColor: Boolean
) {
AnimatedVisibility(
visible = value.isRect(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
AnimatedVisibility(
visible = canChangeFillColor,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
OutlinedFillColorSelector(
value = value.outlinedFillColor?.toColor(),
onValueChange = {
onValueChange(value.updateOutlined(it))
},
shape = ShapeDefaults.top,
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth()
.padding(horizontal = 8.dp),
containerColor = MaterialTheme.colorScheme.surface,
)
}
EnhancedSliderItem(
value = value.rotationDegrees(),
title = stringResource(R.string.angle),
valueRange = 0f..360f,
internalStateTransformation = {
it.roundToInt()
},
onValueChange = {
onValueChange(
value.updateRect(rotationDegrees = it.toInt())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = if (canChangeFillColor) ShapeDefaults.center else ShapeDefaults.top
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = value.cornerRadius(),
title = stringResource(R.string.radius),
valueRange = 0f..0.5f,
internalStateTransformation = { it.roundToTwoDigits() },
onValueChange = {
onValueChange(
value.updateRect(cornerRadius = it.roundToTwoDigits())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.bottom
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,188 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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.retain.retain
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.domain.remote.DownloadProgress
import com.t8rin.imagetoolbox.core.domain.saving.track
import com.t8rin.imagetoolbox.core.domain.saving.updateProgress
import com.t8rin.imagetoolbox.core.domain.utils.throttleLatest
import com.t8rin.imagetoolbox.core.filters.domain.model.enums.SpotHealMode
import com.t8rin.imagetoolbox.core.filters.presentation.utils.LamaLoader
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSettingsState
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSimpleSettingsInteractor
import com.t8rin.imagetoolbox.core.ui.utils.helper.AppToastHost
import com.t8rin.imagetoolbox.core.ui.utils.provider.LocalKeepAliveService
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedCancellableCircularProgressIndicator
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
import com.t8rin.imagetoolbox.core.utils.getString
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
internal fun SpotHealParamsSelector(
value: DrawMode,
onValueChange: (DrawMode) -> Unit
) {
AnimatedVisibility(
visible = value is DrawMode.SpotHeal,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
val keepAliveService = LocalKeepAliveService.current
val settingsState = LocalSettingsState.current
val scope = retain { CoroutineScope(Dispatchers.IO) }
val simpleSettingsInteractor = LocalSimpleSettingsInteractor.current
var downloadJob by retain {
mutableStateOf<Job?>(null)
}
var downloadProgress by remember(LamaLoader.isDownloaded) {
mutableStateOf<DownloadProgress?>(null)
}
var useLama by remember(settingsState.spotHealMode) {
mutableStateOf(settingsState.spotHealMode == 1)
}
LaunchedEffect(LamaLoader.isDownloaded) {
if (!LamaLoader.isDownloaded) {
useLama = false
simpleSettingsInteractor.setSpotHealMode(0)
}
}
LaunchedEffect(useLama) {
onValueChange(
DrawMode.SpotHeal(
if (useLama) SpotHealMode.LaMa else SpotHealMode.OpenCV
)
)
}
PreferenceRowSwitch(
title = stringResource(R.string.generative_inpaint),
subtitle = stringResource(
if (LamaLoader.isDownloaded) R.string.generative_inpaint_ready_sub
else R.string.generative_inpaint_sub
),
checked = useLama,
onClick = { new ->
if (downloadJob == null) {
useLama = new
scope.launch { simpleSettingsInteractor.setSpotHealMode(if (useLama) 1 else 0) }
if (useLama && !LamaLoader.isDownloaded) {
downloadJob?.cancel()
downloadJob = scope.launch {
keepAliveService.track(
initial = {
updateOrStart(
title = getString(R.string.downloading)
)
}
) {
LamaLoader.download()
.onStart {
downloadProgress = DownloadProgress(
currentPercent = 0f,
currentTotalSize = 0
)
}
.onCompletion {
downloadProgress = null
downloadJob = null
}
.catch {
simpleSettingsInteractor.setSpotHealMode(0)
AppToastHost.showFailureToast(it)
useLama = false
downloadProgress = null
downloadJob = null
}
.onEach {
downloadProgress = it
}
.throttleLatest(50)
.collect {
updateProgress(
title = getString(R.string.downloading),
done = (it.currentPercent * 100).roundToInt(),
total = 100
)
}
}
}
}
}
},
contentInsteadOfSwitch = downloadProgress?.let { progress ->
{
EnhancedCancellableCircularProgressIndicator(
progress = { progress.currentPercent },
modifier = Modifier.size(24.dp),
trackColor = MaterialTheme.colorScheme.primary.copy(0.2f),
strokeWidth = 3.dp,
onCancel = {
downloadJob?.cancel()
downloadProgress = null
downloadJob = null
useLama = false
scope.launch {
simpleSettingsInteractor.setSpotHealMode(0)
}
}
)
}
},
containerColor = MaterialTheme.colorScheme.surface,
shape = ShapeDefaults.default,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
resultModifier = Modifier.padding(16.dp),
applyHorizontalPadding = false
)
}
}
@@ -0,0 +1,119 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Square
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.density
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isSpray
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isSquareShaped
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.pixelSize
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateSpray
import kotlin.math.roundToInt
@Composable
internal fun SprayParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit
) {
AnimatedVisibility(
visible = value.isSpray(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
EnhancedSliderItem(
value = value.density(),
title = stringResource(R.string.density),
valueRange = 1f..500f,
internalStateTransformation = {
it.roundToInt()
},
onValueChange = {
onValueChange(
value.updateSpray(density = it.roundToInt())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.top
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = value.pixelSize(),
title = stringResource(R.string.pixel_size),
valueRange = 0.5f..20f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
value.updateSpray(pixelSize = it.roundToTwoDigits())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.center
)
Spacer(modifier = Modifier.height(4.dp))
PreferenceRowSwitch(
title = stringResource(R.string.square_particles),
subtitle = stringResource(R.string.square_particles_sub),
checked = value.isSquareShaped(),
onClick = {
onValueChange(
value.updateSpray(isSquareShaped = it)
)
},
containerColor = MaterialTheme.colorScheme.surface,
shape = ShapeDefaults.bottom,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
resultModifier = Modifier.padding(16.dp),
startIcon = Icons.Rounded.Square
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,160 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.SquareFoot
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.innerRadiusRatio
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isRegular
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.isStar
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.rotationDegrees
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateStar
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.vertices
import kotlin.math.roundToInt
@Composable
internal fun StarParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit,
canChangeFillColor: Boolean
) {
AnimatedVisibility(
visible = value.isStar(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
AnimatedVisibility(
visible = canChangeFillColor,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
OutlinedFillColorSelector(
value = value.outlinedFillColor?.toColor(),
onValueChange = {
onValueChange(value.updateOutlined(it))
},
shape = ShapeDefaults.top,
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth()
.padding(horizontal = 8.dp),
containerColor = MaterialTheme.colorScheme.surface,
)
}
EnhancedSliderItem(
value = value.vertices(),
title = stringResource(R.string.vertices),
valueRange = 3f..24f,
steps = 20,
internalStateTransformation = {
it.roundToInt()
},
onValueChange = {
onValueChange(
value.updateStar(vertices = it.toInt())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = if (canChangeFillColor) ShapeDefaults.center else ShapeDefaults.top
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = value.rotationDegrees(),
title = stringResource(R.string.angle),
valueRange = 0f..360f,
internalStateTransformation = {
it.roundToInt()
},
onValueChange = {
onValueChange(
value.updateStar(rotationDegrees = it.toInt())
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.center
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = value.innerRadiusRatio(),
title = stringResource(R.string.inner_radius_ratio),
valueRange = 0f..1f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
value.updateStar(innerRadiusRatio = it)
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.center
)
Spacer(modifier = Modifier.height(4.dp))
PreferenceRowSwitch(
title = stringResource(R.string.draw_regular_star),
subtitle = stringResource(R.string.draw_regular_star_sub),
checked = value.isRegular(),
onClick = {
onValueChange(
value.updateStar(isRegular = it)
)
},
containerColor = MaterialTheme.colorScheme.surface,
shape = ShapeDefaults.bottom,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
startIcon = Icons.Rounded.SquareFoot,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,154 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.domain.model.pt
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.settings.presentation.model.toUiFont
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.FontSelector
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.animateShape
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceRowSwitch
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextField
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
@Composable
internal fun TextParamsSelector(
value: DrawMode,
onValueChange: (DrawMode) -> Unit
) {
AnimatedVisibility(
visible = value is DrawMode.Text,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
RoundedTextField(
modifier = Modifier
.padding(horizontal = 8.dp)
.container(
shape = ShapeDefaults.top,
color = MaterialTheme.colorScheme.surface,
resultPadding = 8.dp
),
value = (value as? DrawMode.Text)?.text ?: "",
singleLine = false,
onValueChange = {
onValueChange(
(value as? DrawMode.Text)?.copy(
text = it
) ?: value
)
},
label = {
Text(stringResource(R.string.text))
}
)
Spacer(modifier = Modifier.height(4.dp))
FontSelector(
value = (value as? DrawMode.Text)?.font.toUiFont(),
onValueChange = {
onValueChange(
(value as? DrawMode.Text)?.copy(
font = it.type
) ?: value
)
},
modifier = Modifier
.padding(horizontal = 8.dp),
shape = ShapeDefaults.center
)
Spacer(modifier = Modifier.height(4.dp))
val isDashSizeControlVisible = (value as? DrawMode.Text)?.isRepeated == true
PreferenceRowSwitch(
title = stringResource(R.string.repeat_text),
subtitle = stringResource(R.string.repeat_text_sub),
checked = (value as? DrawMode.Text)?.isRepeated == true,
onClick = {
onValueChange(
(value as? DrawMode.Text)?.copy(
isRepeated = it
) ?: value
)
},
containerColor = MaterialTheme.colorScheme.surface,
shape = animateShape(
if (isDashSizeControlVisible) ShapeDefaults.center
else ShapeDefaults.bottom
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
resultModifier = Modifier.padding(16.dp),
applyHorizontalPadding = false
)
Spacer(
modifier = Modifier.height(
if (isDashSizeControlVisible) 4.dp else 8.dp
)
)
AnimatedVisibility(
visible = isDashSizeControlVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
EnhancedSliderItem(
value = (value as? DrawMode.Text)?.repeatingInterval?.value ?: 0f,
title = stringResource(R.string.dash_size),
valueRange = 0f..100f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
(value as? DrawMode.Text)?.copy(
repeatingInterval = it.pt
) ?: value
)
},
containerColor = MaterialTheme.colorScheme.surface,
valueSuffix = " Pt",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(bottom = 8.dp),
shape = ShapeDefaults.bottom
)
}
}
}
}
@@ -0,0 +1,65 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.ui.utils.helper.toColor
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.utils.updateOutlined
@Composable
internal fun TriangleParamsSelector(
value: DrawPathMode,
onValueChange: (DrawPathMode) -> Unit,
canChangeFillColor: Boolean
) {
AnimatedVisibility(
visible = value is DrawPathMode.OutlinedTriangle && canChangeFillColor,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
OutlinedFillColorSelector(
value = value.outlinedFillColor?.toColor(),
onValueChange = {
onValueChange(value.updateOutlined(it))
},
shape = ShapeDefaults.default,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
containerColor = MaterialTheme.colorScheme.surface,
)
Spacer(Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,163 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.element
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.t8rin.colors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.domain.utils.safeCast
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.theme.ImageToolboxThemeForPreview
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.WarpMode
@Composable
internal fun WarpParamsSelector(
value: DrawMode,
onValueChange: (DrawMode) -> Unit
) {
AnimatedVisibility(
visible = value is DrawMode.Warp,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
EnhancedSliderItem(
value = value.safeCast<DrawMode.Warp>()?.strength ?: 0f,
title = stringResource(R.string.strength),
valueRange = 0f..1f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
value.safeCast<DrawMode.Warp>()?.copy(
strength = it.roundToTwoDigits()
) ?: value
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.top
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedSliderItem(
value = value.safeCast<DrawMode.Warp>()?.hardness ?: 0f,
title = stringResource(R.string.hardness),
valueRange = 0f..1f,
internalStateTransformation = {
it.roundToTwoDigits()
},
onValueChange = {
onValueChange(
value.safeCast<DrawMode.Warp>()?.copy(
hardness = it.roundToTwoDigits()
) ?: value
)
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
shape = ShapeDefaults.center
)
Spacer(modifier = Modifier.height(4.dp))
EnhancedButtonGroup(
enabled = true,
modifier = Modifier
.padding(horizontal = 8.dp)
.container(
shape = ShapeDefaults.bottom,
color = MaterialTheme.colorScheme.surface
),
itemCount = WarpMode.entries.size,
title = {
Text(
text = stringResource(R.string.warp_mode),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 4.dp)
)
},
selectedIndex = WarpMode.entries.indexOfFirst {
value.safeCast<DrawMode.Warp>()?.warpMode == it
},
itemContent = {
Text(
text = stringResource(WarpMode.entries[it].title())
)
},
onIndexChange = {
onValueChange(
value.safeCast<DrawMode.Warp>()?.copy(
warpMode = WarpMode.entries[it]
) ?: value
)
},
inactiveButtonColor = MaterialTheme.colorScheme.surfaceContainer
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
private fun WarpMode.title(): Int = when (this) {
WarpMode.MOVE -> R.string.warp_mode_move
WarpMode.GROW -> R.string.warp_mode_grow
WarpMode.SHRINK -> R.string.warp_mode_shrink
WarpMode.SWIRL_CW -> R.string.warp_mode_swirl_cw
WarpMode.SWIRL_CCW -> R.string.warp_mode_swirl_ccw
WarpMode.MIXING -> R.string.mix
}
@Composable
@Preview
private fun Preview() = ImageToolboxThemeForPreview(true) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer
) {
WarpParamsSelector(
value = DrawMode.Warp(),
onValueChange = {}
)
}
}
@@ -0,0 +1,79 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableIntState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.ui.theme.outlineVariant
import com.t8rin.imagetoolbox.core.ui.widget.image.Picture
import com.t8rin.imagetoolbox.core.ui.widget.modifier.HelperGridParams
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.drawHelperGrid
import com.t8rin.imagetoolbox.core.ui.widget.modifier.transparencyChecker
@Composable
fun BoxScope.BitmapDrawerPreview(
preview: ImageBitmap,
globalTouchPointersCount: MutableIntState,
onReceiveMotionEvent: (MotionEvent) -> Unit,
onInvalidate: () -> Unit,
onUpdateCurrentDrawPosition: (Offset) -> Unit,
onUpdateDrawDownPosition: (Offset) -> Unit,
drawEnabled: Boolean,
helperGridParams: HelperGridParams,
drawBitmapBorder: Boolean,
beforeHelperGridModifier: Modifier = Modifier,
) {
Picture(
model = preview,
modifier = Modifier
.matchParentSize()
.pointerDrawHandler(
globalTouchPointersCount = globalTouchPointersCount,
onReceiveMotionEvent = onReceiveMotionEvent,
onInvalidate = onInvalidate,
onUpdateCurrentDrawPosition = onUpdateCurrentDrawPosition,
onUpdateDrawDownPosition = onUpdateDrawDownPosition,
enabled = drawEnabled
)
.clip(ShapeDefaults.extremeSmall)
.transparencyChecker()
.then(beforeHelperGridModifier)
.drawHelperGrid(helperGridParams)
.then(
if (drawBitmapBorder) {
Modifier.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant(),
shape = ShapeDefaults.extremeSmall
)
} else Modifier
),
contentDescription = null,
contentScale = ContentScale.FillBounds
)
}
@@ -0,0 +1,103 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import android.graphics.Bitmap
import android.graphics.PorterDuff
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.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.createScaledBitmap
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.presentation.components.UiPathPaint
import android.graphics.Path as NativePath
@Composable
internal fun DrawPathEffectPreview(
drawPathCanvas: Canvas,
drawMode: DrawMode.PathEffect,
canvasSize: IntegerSize,
imageWidth: Int,
imageHeight: Int,
outputImage: ImageBitmap,
onRequestFiltering: suspend (Bitmap, List<Filter<*>>) -> Bitmap?,
paths: List<UiPathPaint>,
drawPath: Path,
backgroundColor: Color,
strokeWidth: Pt,
drawPathMode: DrawPathMode
) {
var shaderBitmap by remember {
mutableStateOf<ImageBitmap?>(null)
}
LaunchedEffect(outputImage, paths, backgroundColor, drawMode) {
shaderBitmap = onRequestFiltering(
outputImage.asAndroidBitmap(),
transformationsForMode(
drawMode = drawMode,
canvasSize = canvasSize
)
)?.createScaledBitmap(
width = imageWidth,
height = imageHeight
)?.asImageBitmap()
}
shaderBitmap?.let {
with(drawPathCanvas) {
with(nativeCanvas) {
drawColor(Color.Transparent.toArgb(), PorterDuff.Mode.CLEAR)
val paint by rememberPathEffectPaint(
strokeWidth = strokeWidth,
drawPathMode = drawPathMode,
canvasSize = canvasSize
)
val newPath = drawPath.copyAsAndroidPath().apply {
fillType = NativePath.FillType.INVERSE_WINDING
}
val imagePaint = remember { Paint() }
drawImage(
image = it,
topLeftOffset = Offset.Zero,
paint = imagePaint
)
drawPath(newPath, paint)
}
}
}
}
@@ -0,0 +1,523 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import com.t8rin.imagetoolbox.core.resources.Icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import com.t8rin.imagetoolbox.core.data.image.utils.ColorUtils.toColor
import com.t8rin.imagetoolbox.core.data.image.utils.ColorUtils.toModel
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.CheckBoxOutlineBlank
import com.t8rin.imagetoolbox.core.resources.icons.Circle
import com.t8rin.imagetoolbox.core.resources.icons.FloodFill
import com.t8rin.imagetoolbox.core.resources.icons.FreeArrow
import com.t8rin.imagetoolbox.core.resources.icons.FreeDoubleArrow
import com.t8rin.imagetoolbox.core.resources.icons.FreeDraw
import com.t8rin.imagetoolbox.core.resources.icons.Lasso
import com.t8rin.imagetoolbox.core.resources.icons.Line
import com.t8rin.imagetoolbox.core.resources.icons.LineArrow
import com.t8rin.imagetoolbox.core.resources.icons.LineDoubleArrow
import com.t8rin.imagetoolbox.core.resources.icons.Polygon
import com.t8rin.imagetoolbox.core.resources.icons.Spray
import com.t8rin.imagetoolbox.core.resources.icons.Square
import com.t8rin.imagetoolbox.core.resources.icons.Star
import com.t8rin.imagetoolbox.core.resources.icons.Triangle
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
internal fun DrawPathMode.saveState(
value: DrawPathMode
): DrawPathMode = when (value) {
is DrawPathMode.Rect if this is DrawPathMode.OutlinedRect -> {
copy(
rotationDegrees = value.rotationDegrees,
cornerRadius = value.cornerRadius
)
}
is DrawPathMode.OutlinedRect if this is DrawPathMode.Rect -> {
copy(
rotationDegrees = value.rotationDegrees,
cornerRadius = value.cornerRadius
)
}
is DrawPathMode.Polygon if this is DrawPathMode.OutlinedPolygon -> {
copy(
vertices = value.vertices,
rotationDegrees = value.rotationDegrees,
isRegular = value.isRegular
)
}
is DrawPathMode.OutlinedPolygon if this is DrawPathMode.Polygon -> {
copy(
vertices = value.vertices,
rotationDegrees = value.rotationDegrees,
isRegular = value.isRegular
)
}
is DrawPathMode.Star if this is DrawPathMode.OutlinedStar -> {
copy(
vertices = value.vertices,
innerRadiusRatio = innerRadiusRatio,
rotationDegrees = value.rotationDegrees,
isRegular = value.isRegular
)
}
is DrawPathMode.OutlinedStar if this is DrawPathMode.Star -> {
copy(
vertices = value.vertices,
innerRadiusRatio = innerRadiusRatio,
rotationDegrees = value.rotationDegrees,
isRegular = value.isRegular
)
}
is DrawPathMode.PointingArrow if this is DrawPathMode.LinePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.LinePointingArrow if this is DrawPathMode.PointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.PointingArrow if this is DrawPathMode.DoublePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.DoublePointingArrow if this is DrawPathMode.PointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.PointingArrow if this is DrawPathMode.DoubleLinePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.DoubleLinePointingArrow if this is DrawPathMode.PointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.LinePointingArrow if this is DrawPathMode.DoublePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.DoublePointingArrow if this is DrawPathMode.LinePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.LinePointingArrow if this is DrawPathMode.DoubleLinePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.DoubleLinePointingArrow if this is DrawPathMode.LinePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.DoublePointingArrow if this is DrawPathMode.DoubleLinePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.DoubleLinePointingArrow if this is DrawPathMode.DoublePointingArrow -> {
copy(
sizeScale = value.sizeScale,
angle = value.angle
)
}
is DrawPathMode.FloodFill if this is DrawPathMode.FloodFill -> {
copy(
tolerance = value.tolerance
)
}
is DrawPathMode.Spray if this is DrawPathMode.Spray -> {
copy(
density = value.density,
pixelSize = value.pixelSize,
isSquareShaped = value.isSquareShaped
)
}
else -> this
}.run {
if (value is DrawPathMode.Outlined && this is DrawPathMode.Outlined) {
updateOutlined(
fillColor = value.fillColor?.toColor()
)
} else this
}
internal fun DrawPathMode.density(): Int = when (this) {
is DrawPathMode.Spray -> density
else -> 0
}
internal fun DrawPathMode.pixelSize(): Float = when (this) {
is DrawPathMode.Spray -> pixelSize
else -> 0f
}
internal fun DrawPathMode.isSquareShaped(): Boolean = when (this) {
is DrawPathMode.Spray -> isSquareShaped
else -> false
}
internal fun DrawPathMode.tolerance(): Float = when (this) {
is DrawPathMode.FloodFill -> tolerance
else -> 0f
}
internal fun DrawPathMode.sizeScale(): Float = when (this) {
is DrawPathMode.PointingArrow -> sizeScale
is DrawPathMode.LinePointingArrow -> sizeScale
is DrawPathMode.DoublePointingArrow -> sizeScale
is DrawPathMode.DoubleLinePointingArrow -> sizeScale
else -> 1f
}
internal fun DrawPathMode.angle(): Float = when (this) {
is DrawPathMode.PointingArrow -> angle
is DrawPathMode.LinePointingArrow -> angle
is DrawPathMode.DoublePointingArrow -> angle
is DrawPathMode.DoubleLinePointingArrow -> angle
else -> 0f
}
internal fun DrawPathMode.vertices(): Int = when (this) {
is DrawPathMode.Polygon -> vertices
is DrawPathMode.OutlinedPolygon -> vertices
is DrawPathMode.Star -> vertices
is DrawPathMode.OutlinedStar -> vertices
else -> 0
}
internal fun DrawPathMode.rotationDegrees(): Int = when (this) {
is DrawPathMode.Polygon -> rotationDegrees
is DrawPathMode.OutlinedPolygon -> rotationDegrees
is DrawPathMode.Star -> rotationDegrees
is DrawPathMode.OutlinedStar -> rotationDegrees
is DrawPathMode.Rect -> rotationDegrees
is DrawPathMode.OutlinedRect -> rotationDegrees
else -> 0
}
internal fun DrawPathMode.cornerRadius(): Float = when (this) {
is DrawPathMode.Rect -> cornerRadius
is DrawPathMode.OutlinedRect -> cornerRadius
else -> 0f
}
internal fun DrawPathMode.isRegular(): Boolean = when (this) {
is DrawPathMode.Polygon -> isRegular
is DrawPathMode.OutlinedPolygon -> isRegular
is DrawPathMode.Star -> isRegular
is DrawPathMode.OutlinedStar -> isRegular
else -> false
}
internal fun DrawPathMode.innerRadiusRatio(): Float = when (this) {
is DrawPathMode.Star -> innerRadiusRatio
is DrawPathMode.OutlinedStar -> innerRadiusRatio
else -> 0.5f
}
internal fun DrawPathMode.updateOutlined(
fillColor: Color?
) = when (this) {
is DrawPathMode.Outlined -> {
when (this) {
is DrawPathMode.OutlinedOval -> copy(
fillColor = fillColor?.toModel()
)
is DrawPathMode.OutlinedPolygon -> copy(
fillColor = fillColor?.toModel()
)
is DrawPathMode.OutlinedRect -> copy(
fillColor = fillColor?.toModel()
)
is DrawPathMode.OutlinedStar -> copy(
fillColor = fillColor?.toModel()
)
is DrawPathMode.OutlinedTriangle -> copy(
fillColor = fillColor?.toModel()
)
}
}
else -> this
}
internal fun DrawPathMode.updatePolygon(
vertices: Int? = null,
rotationDegrees: Int? = null,
isRegular: Boolean? = null
) = when (this) {
is DrawPathMode.Polygon -> {
copy(
vertices = vertices ?: this.vertices,
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
isRegular = isRegular ?: this.isRegular
)
}
is DrawPathMode.OutlinedPolygon -> {
copy(
vertices = vertices ?: this.vertices,
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
isRegular = isRegular ?: this.isRegular
)
}
else -> this
}
internal fun DrawPathMode.updateStar(
vertices: Int? = null,
innerRadiusRatio: Float? = null,
rotationDegrees: Int? = null,
isRegular: Boolean? = null
) = when (this) {
is DrawPathMode.Star -> {
copy(
vertices = vertices ?: this.vertices,
innerRadiusRatio = innerRadiusRatio ?: this.innerRadiusRatio,
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
isRegular = isRegular ?: this.isRegular
)
}
is DrawPathMode.OutlinedStar -> {
copy(
vertices = vertices ?: this.vertices,
innerRadiusRatio = innerRadiusRatio ?: this.innerRadiusRatio,
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
isRegular = isRegular ?: this.isRegular
)
}
else -> this
}
internal fun DrawPathMode.updateRect(
rotationDegrees: Int? = null,
cornerRadius: Float? = null
) = when (this) {
is DrawPathMode.Rect -> {
copy(
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
cornerRadius = cornerRadius ?: this.cornerRadius
)
}
is DrawPathMode.OutlinedRect -> {
copy(
rotationDegrees = rotationDegrees ?: this.rotationDegrees,
cornerRadius = cornerRadius ?: this.cornerRadius
)
}
else -> this
}
internal fun DrawPathMode.updateArrow(
sizeScale: Float? = null,
angle: Float? = null
) = when (this) {
is DrawPathMode.PointingArrow -> {
copy(
sizeScale = sizeScale ?: this.sizeScale,
angle = angle ?: this.angle
)
}
is DrawPathMode.LinePointingArrow -> {
copy(
sizeScale = sizeScale ?: this.sizeScale,
angle = angle ?: this.angle
)
}
is DrawPathMode.DoublePointingArrow -> {
copy(
sizeScale = sizeScale ?: this.sizeScale,
angle = angle ?: this.angle
)
}
is DrawPathMode.DoubleLinePointingArrow -> {
copy(
sizeScale = sizeScale ?: this.sizeScale,
angle = angle ?: this.angle
)
}
else -> this
}
internal fun DrawPathMode.updateFloodFill(
tolerance: Float? = null,
) = when (this) {
is DrawPathMode.FloodFill -> {
copy(
tolerance = tolerance ?: this.tolerance
)
}
else -> this
}
internal fun DrawPathMode.updateSpray(
density: Int? = null,
pixelSize: Float? = null,
isSquareShaped: Boolean? = null,
) = when (this) {
is DrawPathMode.Spray -> {
copy(
density = density ?: this.density,
pixelSize = pixelSize ?: this.pixelSize,
isSquareShaped = isSquareShaped ?: this.isSquareShaped
)
}
else -> this
}
internal fun DrawPathMode.isArrow(): Boolean =
this is DrawPathMode.PointingArrow || this is DrawPathMode.LinePointingArrow
|| this is DrawPathMode.DoublePointingArrow || this is DrawPathMode.DoubleLinePointingArrow
internal fun DrawPathMode.isRect(): Boolean =
this is DrawPathMode.Rect || this is DrawPathMode.OutlinedRect
internal fun DrawPathMode.isPolygon(): Boolean =
this is DrawPathMode.Polygon || this is DrawPathMode.OutlinedPolygon
internal fun DrawPathMode.isStar(): Boolean =
this is DrawPathMode.Star || this is DrawPathMode.OutlinedStar
internal fun DrawPathMode.isFloodFill(): Boolean =
this is DrawPathMode.FloodFill
internal fun DrawPathMode.isSpray(): Boolean =
this is DrawPathMode.Spray
internal fun DrawPathMode.getSubtitle(): Int = when (this) {
is DrawPathMode.DoubleLinePointingArrow -> R.string.double_line_arrow_sub
is DrawPathMode.DoublePointingArrow -> R.string.double_arrow_sub
DrawPathMode.Free -> R.string.free_drawing_sub
DrawPathMode.Line -> R.string.line_sub
is DrawPathMode.LinePointingArrow -> R.string.line_arrow_sub
is DrawPathMode.PointingArrow -> R.string.arrow_sub
is DrawPathMode.OutlinedOval -> R.string.outlined_oval_sub
is DrawPathMode.OutlinedRect -> R.string.outlined_rect_sub
DrawPathMode.Oval -> R.string.oval_sub
is DrawPathMode.Rect -> R.string.rect_sub
DrawPathMode.Lasso -> R.string.lasso_sub
is DrawPathMode.OutlinedTriangle -> R.string.outlined_triangle_sub
DrawPathMode.Triangle -> R.string.triangle_sub
is DrawPathMode.Polygon -> R.string.polygon_sub
is DrawPathMode.OutlinedPolygon -> R.string.outlined_polygon_sub
is DrawPathMode.OutlinedStar -> R.string.outlined_star_sub
is DrawPathMode.Star -> R.string.star_sub
is DrawPathMode.FloodFill -> R.string.flood_fill_sub
is DrawPathMode.Spray -> R.string.spray_sub
}
internal fun DrawPathMode.getTitle(): Int = when (this) {
is DrawPathMode.DoubleLinePointingArrow -> R.string.double_line_arrow
is DrawPathMode.DoublePointingArrow -> R.string.double_arrow
DrawPathMode.Free -> R.string.free_drawing
DrawPathMode.Line -> R.string.line
is DrawPathMode.LinePointingArrow -> R.string.line_arrow
is DrawPathMode.PointingArrow -> R.string.arrow
is DrawPathMode.OutlinedOval -> R.string.outlined_oval
is DrawPathMode.OutlinedRect -> R.string.outlined_rect
DrawPathMode.Oval -> R.string.oval
is DrawPathMode.Rect -> R.string.rect
DrawPathMode.Lasso -> R.string.lasso
is DrawPathMode.OutlinedTriangle -> R.string.outlined_triangle
DrawPathMode.Triangle -> R.string.triangle
is DrawPathMode.Polygon -> R.string.polygon
is DrawPathMode.OutlinedPolygon -> R.string.outlined_polygon
is DrawPathMode.OutlinedStar -> R.string.outlined_star
is DrawPathMode.Star -> R.string.star
is DrawPathMode.FloodFill -> R.string.flood_fill
is DrawPathMode.Spray -> R.string.spray
}
internal fun DrawPathMode.getIcon(): ImageVector = when (this) {
is DrawPathMode.DoubleLinePointingArrow -> Icons.Rounded.LineDoubleArrow
is DrawPathMode.DoublePointingArrow -> Icons.Rounded.FreeDoubleArrow
DrawPathMode.Free -> Icons.Rounded.FreeDraw
DrawPathMode.Line -> Icons.Rounded.Line
is DrawPathMode.LinePointingArrow -> Icons.Rounded.LineArrow
is DrawPathMode.PointingArrow -> Icons.Rounded.FreeArrow
is DrawPathMode.OutlinedOval -> Icons.Outlined.Circle
is DrawPathMode.OutlinedRect -> Icons.Rounded.CheckBoxOutlineBlank
DrawPathMode.Oval -> Icons.Rounded.Circle
is DrawPathMode.Rect -> Icons.Rounded.Square
DrawPathMode.Lasso -> Icons.Rounded.Lasso
DrawPathMode.Triangle -> Icons.Rounded.Triangle
is DrawPathMode.OutlinedTriangle -> Icons.Outlined.Triangle
is DrawPathMode.Polygon -> Icons.Rounded.Polygon
is DrawPathMode.OutlinedPolygon -> Icons.Outlined.Polygon
is DrawPathMode.OutlinedStar -> Icons.Outlined.Star
is DrawPathMode.Star -> Icons.Rounded.Star
is DrawPathMode.FloodFill -> Icons.Rounded.FloodFill
is DrawPathMode.Spray -> Icons.Outlined.Spray
}
@@ -0,0 +1,96 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PathMeasure
import androidx.core.graphics.withSave
import kotlin.math.atan2
fun Canvas.drawRepeatedTextOnPath(
text: String,
path: Path,
paint: Paint,
interval: Float = 0f
) {
val pathMeasure = PathMeasure(path, false)
val pathLength = pathMeasure.length
val textWidth = paint.measureText(text)
val fullRepeats = (pathLength / (textWidth + interval)).toInt()
val remainingLength = pathLength - fullRepeats * (textWidth + interval)
var distance = 0f
repeat(fullRepeats) {
drawTextOnPath(text, path, distance, 0f, paint)
distance += (textWidth + interval)
}
if (remainingLength > 0f) {
val ratio = (textWidth + interval - (remainingLength)) / (textWidth + interval)
val endOffset = (text.length - (text.length * ratio).toInt()).coerceAtLeast(0)
drawTextOnPath(text.substring(0, endOffset), path, distance, 0f, paint)
}
}
fun Canvas.drawRepeatedBitmapOnPath(
bitmap: Bitmap,
path: Path,
paint: Paint,
interval: Float = 0f
) {
val pathMeasure = PathMeasure(path, false)
val pathLength = pathMeasure.length
val bitmapWidth = bitmap.width.toFloat()
val bitmapHeight = bitmap.height.toFloat()
var distance = 0f
val matrix = Matrix()
while (distance < pathLength) {
val pos = FloatArray(2)
val tan = FloatArray(2)
pathMeasure.getPosTan(distance, pos, tan)
val degree = Math.toDegrees(atan2(tan[1].toDouble(), tan[0].toDouble())).toFloat()
withSave {
translate(pos[0], pos[1])
rotate(degree)
matrix.reset()
matrix.postTranslate(-bitmapWidth / 2, -bitmapHeight / 2)
matrix.postRotate(degree)
matrix.postTranslate(bitmapWidth / 2, bitmapHeight / 2)
drawBitmap(bitmap, matrix, paint)
}
if (interval < 0 && distance + bitmapWidth < 0) break
else {
distance += (bitmapWidth + interval)
}
}
}
@@ -0,0 +1,521 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Matrix
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
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.PaintingStyle
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.StampedPathEffectStyle
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativePaint
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.LayoutDirection
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.createBitmap
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import com.t8rin.imagetoolbox.core.data.image.utils.drawBitmap
import com.t8rin.imagetoolbox.core.data.utils.safeConfig
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.domain.model.max
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.filters.presentation.model.UiNativeStackBlurFilter
import com.t8rin.imagetoolbox.core.filters.presentation.model.UiPixelationFilter
import com.t8rin.imagetoolbox.core.filters.presentation.model.toUiFilter
import com.t8rin.imagetoolbox.core.resources.shapes.MaterialStarShape
import com.t8rin.imagetoolbox.core.ui.utils.helper.ContextUtils.density
import com.t8rin.imagetoolbox.core.ui.widget.modifier.Line
import com.t8rin.imagetoolbox.core.utils.appContext
import com.t8rin.imagetoolbox.core.utils.toTypeface
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import kotlin.math.roundToInt
import kotlin.math.sqrt
import android.graphics.Canvas as NativeCanvas
import android.graphics.Paint as NativePaint
import android.graphics.Path as NativePath
/**
* Needed to trigger recomposition
**/
fun ImageBitmap.copy(): ImageBitmap = asAndroidBitmap().asImageBitmap()
internal fun Path.copy(): Path = copyAsAndroidPath().asComposePath()
internal fun Path.copyAsAndroidPath(): NativePath = NativePath(this.asAndroidPath())
internal fun NativePath.mirror(
x: Float,
y: Float,
x1: Float,
y1: Float
): NativePath {
val dx = x1 - x
val dy = y1 - y
val lengthSq = dx * dx + dy * dy
val matrix = Matrix().apply {
setValues(
floatArrayOf(
(dx * dx - dy * dy) / lengthSq,
(2 * dx * dy) / lengthSq,
(2 * x * dy * dy - 2 * y * dx * dy) / lengthSq,
(2 * dx * dy) / lengthSq,
(dy * dy - dx * dx) / lengthSq,
(2 * y * dx * dx - 2 * x * dx * dy) / lengthSq,
0f,
0f,
1f
)
)
}
val mirroredPath = NativePath()
this.transform(matrix, mirroredPath)
return mirroredPath
}
@Suppress("unused")
internal fun Path.mirrorIfNeeded(
canvasSize: IntegerSize,
mirroringLines: List<Line>
): Path = asAndroidPath().mirrorIfNeeded(
canvasSize = canvasSize,
mirroringLines = mirroringLines
).asComposePath()
internal fun NativePath.mirrorIfNeeded(
canvasSize: IntegerSize,
mirroringLines: List<Line>
): NativePath = if (mirroringLines.isNotEmpty()) {
NativePath(this).apply {
mirroringLines.forEach { mirroringLine ->
addPath(
mirror(
x = mirroringLine.startX * canvasSize.width,
y = mirroringLine.startY * canvasSize.height,
x1 = mirroringLine.endX * canvasSize.width,
y1 = mirroringLine.endY * canvasSize.height
)
)
}
}
} else {
this
}
@Suppress("unused")
internal fun Path.mirror(
x: Float,
y: Float,
x1: Float,
y1: Float
): Path = asAndroidPath().mirror(
x = x,
y = y,
x1 = x1,
y1 = y1
).asComposePath()
@Suppress("unused")
fun Canvas.drawInfiniteLine(
line: Line,
paint: NativePaint = NativePaint().apply {
color = Color.Red.toArgb()
style = NativePaint.Style.STROKE
strokeWidth = 5f
}
) {
val width = width.toFloat()
val height = height.toFloat()
val startX = line.startX * width
val startY = line.startY * height
val endX = line.endX * width
val endY = line.endY * height
val dx = endX - startX
val dy = endY - startY
if (dx == 0f) {
drawLine(startX, 0f, startX, height, paint)
return
}
if (dy == 0f) {
drawLine(0f, startY, width, startY, paint)
return
}
val directionX = dx / sqrt(dx * dx + dy * dy)
val directionY = dy / sqrt(dx * dx + dy * dy)
val scale = maxOf(width, height) * 2
val extendedStartX = startX - directionX * scale
val extendedStartY = startY - directionY * scale
val extendedEndX = endX + directionX * scale
val extendedEndY = endY + directionY * scale
drawLine(extendedStartX, extendedStartY, extendedEndX, extendedEndY, paint)
}
internal fun ImageBitmap.clipBitmap(
path: Path,
paint: Paint,
): ImageBitmap = asAndroidBitmap()
.let { it.copy(it.safeConfig, true) }
.applyCanvas {
drawPath(
NativePath(path.asAndroidPath()).apply {
fillType = NativePath.FillType.INVERSE_WINDING
},
paint.nativePaint
)
}.asImageBitmap()
internal fun ImageBitmap.overlay(overlay: ImageBitmap): ImageBitmap {
val image = this.asAndroidBitmap()
return createBitmap(
width = image.width,
height = image.height,
config = image.safeConfig
).applyCanvas {
drawBitmap(image)
drawBitmap(overlay.asAndroidBitmap())
}.asImageBitmap()
}
@Composable
internal fun rememberPaint(
strokeWidth: Pt,
isEraserOn: Boolean,
drawColor: Color,
brushSoftness: Pt,
drawMode: DrawMode,
canvasSize: IntegerSize,
drawPathMode: DrawPathMode,
drawLineStyle: DrawLineStyle
): State<NativePaint> {
val context = LocalContext.current
return remember(
strokeWidth,
isEraserOn,
drawColor,
brushSoftness,
drawMode,
canvasSize,
drawPathMode,
context,
drawLineStyle
) {
derivedStateOf {
val isSharpEdge = drawPathMode.isSharpEdge
val isFilled = drawPathMode.isFilled
Paint().apply {
if (drawMode !is DrawMode.Text && drawMode !is DrawMode.Image) {
pathEffect = drawLineStyle.asPathEffect(
canvasSize = canvasSize,
strokeWidth = strokeWidth.toPx(canvasSize),
context = context
)
}
blendMode = if (!isEraserOn) blendMode else BlendMode.Clear
if (isEraserOn) {
style = PaintingStyle.Stroke
this.strokeWidth = strokeWidth.toPx(canvasSize)
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
} else {
if (drawMode !is DrawMode.Text) {
if (isFilled) {
style = PaintingStyle.Fill
} else {
style = PaintingStyle.Stroke
this.strokeWidth = drawPathMode.convertStrokeWidth(
strokeWidth = strokeWidth,
canvasSize = canvasSize
)
if (drawMode is DrawMode.Highlighter || isSharpEdge) {
strokeCap = StrokeCap.Square
} else {
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
}
}
}
}
color = if (drawMode is DrawMode.PathEffect) {
Color.Transparent
} else drawColor
alpha = drawColor.alpha
}.nativePaint.apply {
if (drawMode is DrawMode.Neon && !isEraserOn) {
this.color = Color.White.toArgb()
setShadowLayer(
brushSoftness.toPx(canvasSize),
0f,
0f,
drawColor
.copy(alpha = .8f)
.toArgb()
)
} else if (brushSoftness.value > 0f) {
maskFilter = BlurMaskFilter(
brushSoftness.toPx(canvasSize),
BlurMaskFilter.Blur.NORMAL
)
}
if (drawMode is DrawMode.Text && !isEraserOn) {
isAntiAlias = true
textSize = strokeWidth.toPx(canvasSize)
typeface = drawMode.font.toTypeface()
}
}
}
}
}
fun pathEffectPaint(
strokeWidth: Pt,
drawPathMode: DrawPathMode,
canvasSize: IntegerSize,
): NativePaint {
val isSharpEdge = drawPathMode.isSharpEdge
val isFilled = drawPathMode.isFilled
return Paint().apply {
if (isFilled) {
style = PaintingStyle.Fill
} else {
style = PaintingStyle.Stroke
this.strokeWidth = strokeWidth.toPx(canvasSize)
if (isSharpEdge) {
strokeCap = StrokeCap.Square
} else {
strokeCap = StrokeCap.Round
strokeJoin = StrokeJoin.Round
}
}
color = Color.Transparent
blendMode = BlendMode.Clear
}.nativePaint
}
@Composable
fun rememberPathEffectPaint(
strokeWidth: Pt,
drawPathMode: DrawPathMode,
canvasSize: IntegerSize,
): State<NativePaint> = remember(
strokeWidth,
drawPathMode,
canvasSize
) {
derivedStateOf {
pathEffectPaint(
strokeWidth = strokeWidth,
drawPathMode = drawPathMode,
canvasSize = canvasSize
)
}
}
internal fun DrawLineStyle.asPathEffect(
canvasSize: IntegerSize,
strokeWidth: Float,
context: Context
): PathEffect? = when (this) {
is DrawLineStyle.Dashed -> {
PathEffect.dashPathEffect(
intervals = floatArrayOf(
size.toPx(canvasSize),
gap.toPx(canvasSize) + strokeWidth
),
phase = 0f
)
}
DrawLineStyle.DotDashed -> {
val dashOnInterval1 = strokeWidth * 4
val dashOffInterval1 = strokeWidth * 2
val dashOnInterval2 = strokeWidth / 4
val dashOffInterval2 = strokeWidth * 2
PathEffect.dashPathEffect(
intervals = floatArrayOf(
dashOnInterval1,
dashOffInterval1,
dashOnInterval2,
dashOffInterval2
),
phase = 0f
)
}
is DrawLineStyle.Stamped<*> -> {
fun Shape.toPath(): Path = Path().apply {
addOutline(
createOutline(
size = Size(strokeWidth, strokeWidth),
layoutDirection = LayoutDirection.Ltr,
density = context.density
)
)
}
val path: Path? = when (shape) {
is Shape -> shape.toPath()
is NativePath -> shape.asComposePath()
is Path -> shape
null -> MaterialStarShape.toPath()
else -> null
}
path?.let {
PathEffect.stampedPathEffect(
shape = it,
advance = spacing.toPx(canvasSize) + strokeWidth,
phase = 0f,
style = StampedPathEffectStyle.Morph
)
}
}
is DrawLineStyle.ZigZag -> {
val zigZagPath = Path().apply {
val zigZagLineWidth = strokeWidth / heightRatio
val shapeVerticalOffset = (strokeWidth / 2) / 2
val shapeHorizontalOffset = (strokeWidth / 2) / 2
moveTo(0f, 0f)
lineTo(strokeWidth / 2, strokeWidth / 2)
lineTo(strokeWidth, 0f)
lineTo(strokeWidth, 0f + zigZagLineWidth)
lineTo(strokeWidth / 2, strokeWidth / 2 + zigZagLineWidth)
lineTo(0f, 0f + zigZagLineWidth)
translate(Offset(-shapeHorizontalOffset, -shapeVerticalOffset))
}
PathEffect.stampedPathEffect(
shape = zigZagPath,
advance = strokeWidth,
phase = 0f,
style = StampedPathEffectStyle.Morph
)
}
DrawLineStyle.None -> null
}
@SuppressLint("ComposableNaming")
@Composable
internal fun NativeCanvas.drawRepeatedImageOnPath(
drawMode: DrawMode.Image,
strokeWidth: Pt,
canvasSize: IntegerSize,
path: NativePath,
paint: NativePaint,
invalidations: Int
) {
var pathImage by remember(strokeWidth, canvasSize, drawMode.imageData) {
mutableStateOf<Bitmap?>(null)
}
LaunchedEffect(pathImage, drawMode.imageData, strokeWidth, canvasSize, invalidations) {
if (pathImage == null) {
pathImage = appContext.imageLoader.execute(
ImageRequest.Builder(appContext)
.data(drawMode.imageData)
.size(strokeWidth.toPx(canvasSize).roundToInt())
.build()
).image?.toBitmap()
}
}
pathImage?.let { bitmap ->
drawRepeatedBitmapOnPath(
bitmap = bitmap,
path = path,
paint = paint,
interval = drawMode.repeatingInterval.toPx(canvasSize)
)
}
}
internal fun transformationsForMode(
drawMode: DrawMode,
canvasSize: IntegerSize
): List<Filter<*>> = when (drawMode) {
is DrawMode.PathEffect.PrivacyBlur -> {
listOf(
UiNativeStackBlurFilter(
value = drawMode.blurRadius.toFloat() / 1000 * max(canvasSize)
)
)
}
is DrawMode.PathEffect.Pixelation -> {
listOf(
UiNativeStackBlurFilter(
value = 20f / 1000 * max(canvasSize)
),
UiPixelationFilter(
value = drawMode.pixelSize / 1000 * max(canvasSize)
)
)
}
is DrawMode.PathEffect.Custom -> {
drawMode.filter?.let {
listOf(it.toUiFilter())
} ?: emptyList()
}
else -> emptyList()
}
@@ -0,0 +1,199 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Path
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asComposePath
import com.t8rin.imagetoolbox.core.utils.makeLog
import java.util.LinkedList
import java.util.Queue
import kotlin.math.roundToInt
import androidx.compose.ui.graphics.Path as ComposePath
internal class FloodFill(image: Bitmap) {
private val path = Path()
private val width: Int = image.width
private val height: Int = image.height
private val pixels: IntArray = IntArray(width * height)
private lateinit var pixelsChecked: BooleanArray
private lateinit var ranges: Queue<FloodFillRange>
private var tolerance = 0
private var startColorRed = 0
private var startColorGreen = 0
private var startColorBlue = 0
private var startColorAlpha = 0
init {
image.getPixels(pixels, 0, width, 0, 0, width, height)
}
private fun prepare() {
// Called before starting flood-fill
pixelsChecked = BooleanArray(pixels.size)
ranges = LinkedList()
}
// Fills the specified point on the bitmap with the currently selected fill color.
// int x, int y: The starting coordinates for the fill
fun performFloodFill(
x: Int,
y: Int,
tolerance: Float
): Path? {
if (x >= width || y >= height || x < 0 || y < 0) return null
path.rewind()
this.tolerance = (tolerance * 255).roundToInt().coerceIn(0, 255)
// Setup
prepare()
// Get starting color.
val startPixel = pixels.getOrNull(width * y + x) ?: return null
startColorRed = Color.red(startPixel)
startColorGreen = Color.green(startPixel)
startColorBlue = Color.blue(startPixel)
startColorAlpha = Color.alpha(startPixel)
// Do first call to flood-fill.
linearFill(x, y)
// Call flood-fill routine while flood-fill ranges still exist on the queue
var range: FloodFillRange
while (ranges.isNotEmpty()) {
// Get Next Range Off the Queue
range = ranges.remove()
// Check Above and Below Each Pixel in the flood-fill Range
var downPxIdx = width * (range.Y + 1) + range.startX
var upPxIdx = width * (range.Y - 1) + range.startX
val upY = range.Y - 1 // so we can pass the y coordinate by ref
val downY = range.Y + 1
for (i in range.startX..range.endX) {
// Start Fill Upwards
// if we're not above the top of the bitmap and the pixel above this one is within the color tolerance
if (range.Y > 0 && !pixelsChecked[upPxIdx] && isPixelColorWithinTolerance(upPxIdx)) {
linearFill(i, upY)
}
// Start Fill Downwards
// if we're not below the bottom of the bitmap and the pixel below this one is within the color tolerance
if (
range.Y < height - 1 && !pixelsChecked[downPxIdx]
&& isPixelColorWithinTolerance(downPxIdx)
) {
linearFill(i, downY)
}
downPxIdx++
upPxIdx++
}
}
return path
}
// Finds the furthermost left and right boundaries of the fill area
// on a given y coordinate, starting from a given x coordinate, filling as it goes.
// Adds the resulting horizontal range to the queue of flood-fill ranges,
// to be processed in the main loop.
//
// int x, int y: The starting coordinates
private fun linearFill(x: Int, y: Int) {
// Find Left Edge of Color Area
var lFillLoc = x // the location to check/fill on the left
var pxIdx = width * y + x
path.moveTo(x.toFloat(), y.toFloat())
while (true) {
pixelsChecked[pxIdx] = true
lFillLoc--
pxIdx--
// exit loop if we're at edge of bitmap or color area
if (lFillLoc < 0 || pixelsChecked[pxIdx] || !isPixelColorWithinTolerance(pxIdx)) {
break
}
}
vectorFill(pxIdx + 1)
lFillLoc++
// Find Right Edge of Color Area
var rFillLoc = x // the location to check/fill on the left
pxIdx = width * y + x
while (true) {
pixelsChecked[pxIdx] = true
rFillLoc++
pxIdx++
if (rFillLoc >= width || pixelsChecked[pxIdx] || !isPixelColorWithinTolerance(pxIdx)) {
break
}
}
vectorFill(pxIdx - 1)
rFillLoc--
// add range to queue
val r = FloodFillRange(lFillLoc, rFillLoc, y)
ranges.offer(r)
}
// vector fill pixels with color
private fun vectorFill(pxIndex: Int) {
val x = (pxIndex % width).toFloat()
val y = (pxIndex - x) / width
path.lineTo(x, y)
}
// Sees if a pixel is within the color tolerance range.
private fun isPixelColorWithinTolerance(px: Int): Boolean {
val alpha = pixels[px] ushr 24 and 0xff
val red = pixels[px] ushr 16 and 0xff
val green = pixels[px] ushr 8 and 0xff
val blue = pixels[px] and 0xff
return alpha >= startColorAlpha - tolerance && alpha <= startColorAlpha + tolerance &&
red >= startColorRed - tolerance && red <= startColorRed + tolerance &&
green >= startColorGreen - tolerance && green <= startColorGreen + tolerance &&
blue >= startColorBlue - tolerance && blue <= startColorBlue + tolerance
}
// Represents a linear range to be filled and branched from.
private data class FloodFillRange(
val startX: Int,
val endX: Int,
val Y: Int
)
}
fun ImageBitmap.floodFill(
offset: Offset,
tolerance: Float
): ComposePath? = runCatching {
FloodFill(
asAndroidBitmap().copy(Bitmap.Config.ARGB_8888, false)
).performFloodFill(
x = offset.x.roundToInt().coerceIn(0, width - 1),
y = offset.y.roundToInt().coerceIn(0, height - 1),
tolerance = tolerance
)?.asComposePath()
}.onFailure { it.makeLog("floodFill") }.getOrNull()
@@ -0,0 +1,38 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
enum class MotionEvent {
Idle,
Down,
Move,
Up;
}
inline fun MotionEvent.handle(
onDown: () -> Unit,
onMove: () -> Unit,
onUp: () -> Unit
) {
when (this) {
MotionEvent.Down -> onDown()
MotionEvent.Move -> onMove()
MotionEvent.Up -> onUp()
else -> Unit
}
}
@@ -0,0 +1,537 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import android.graphics.Matrix
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.geometry.takeOrElse
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePath
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Pt
import com.t8rin.imagetoolbox.core.ui.utils.helper.rotate
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
data class PathHelper(
val drawDownPosition: Offset,
val currentDrawPosition: Offset,
val onPathChange: (Path) -> Unit,
val strokeWidth: Pt,
val canvasSize: IntegerSize,
val drawPathMode: DrawPathMode,
val isEraserOn: Boolean,
val drawMode: DrawMode
) {
private val strokeWidthSized = strokeWidth.toPx(canvasSize)
private val drawArrowsScope by lazy {
object : DrawArrowsScope {
override fun drawArrowsIfNeeded(
drawPath: Path,
) {
fun drawStartEndArrows(
sizeScale: Float = 3f,
angle: Float = 150f
) {
drawEndArrow(
drawPath = drawPath,
arrowSize = sizeScale,
arrowAngle = angle.toDouble()
)
drawStartArrow(
drawPath = drawPath,
arrowSize = sizeScale,
arrowAngle = angle.toDouble()
)
}
when (drawPathMode) {
is DrawPathMode.DoublePointingArrow -> {
drawStartEndArrows(
sizeScale = drawPathMode.sizeScale,
angle = drawPathMode.angle
)
}
is DrawPathMode.DoubleLinePointingArrow -> {
drawStartEndArrows(
sizeScale = drawPathMode.sizeScale,
angle = drawPathMode.angle
)
}
is DrawPathMode.PointingArrow -> {
drawEndArrow(
drawPath = drawPath,
arrowSize = drawPathMode.sizeScale,
arrowAngle = drawPathMode.angle.toDouble()
)
}
is DrawPathMode.LinePointingArrow -> {
drawEndArrow(
drawPath = drawPath,
arrowSize = drawPathMode.sizeScale,
arrowAngle = drawPathMode.angle.toDouble()
)
}
else -> Unit
}
}
private fun drawEndArrow(
drawPath: Path,
arrowSize: Float = 3f,
arrowAngle: Double = 150.0
) {
val (preLastPoint, lastPoint) = PathMeasure().apply {
setPath(drawPath, false)
}.let {
Pair(
it.getPosition(it.length - strokeWidthSized * arrowSize)
.takeOrElse { Offset.Zero },
it.getPosition(it.length).takeOrElse { Offset.Zero }
)
}
val arrowVector = lastPoint - preLastPoint
fun drawArrow() {
val (rx1, ry1) = arrowVector.rotate(arrowAngle)
val (rx2, ry2) = arrowVector.rotate(360 - arrowAngle)
drawPath.apply {
relativeLineTo(rx1, ry1)
moveTo(lastPoint.x, lastPoint.y)
relativeLineTo(rx2, ry2)
}
}
if (abs(arrowVector.x) < arrowSize * strokeWidthSized &&
abs(arrowVector.y) < arrowSize * strokeWidthSized &&
preLastPoint != Offset.Zero
) {
drawArrow()
}
}
private fun drawStartArrow(
drawPath: Path,
arrowSize: Float = 3f,
arrowAngle: Double = 150.0
) {
val (firstPoint, secondPoint) = PathMeasure().apply {
setPath(drawPath, false)
}.let {
Pair(
it.getPosition(0f).takeOrElse { Offset.Zero },
it.getPosition(strokeWidthSized * arrowSize)
.takeOrElse { Offset.Zero }
)
}
val arrowVector = firstPoint - secondPoint
fun drawArrow() {
val (rx1, ry1) = arrowVector.rotate(arrowAngle)
val (rx2, ry2) = arrowVector.rotate(360 - arrowAngle)
drawPath.apply {
moveTo(firstPoint.x, firstPoint.y)
relativeLineTo(rx1, ry1)
moveTo(firstPoint.x, firstPoint.y)
relativeLineTo(rx2, ry2)
}
}
if (abs(arrowVector.x) < arrowSize * strokeWidthSized &&
abs(arrowVector.y) < arrowSize * strokeWidthSized &&
secondPoint != Offset.Zero
) {
drawArrow()
}
}
}
}
fun drawPolygon(
vertices: Int,
rotationDegrees: Int,
isRegular: Boolean,
) {
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
val top = max(drawDownPosition.y, currentDrawPosition.y)
val left = min(drawDownPosition.x, currentDrawPosition.x)
val bottom = min(drawDownPosition.y, currentDrawPosition.y)
val right = max(drawDownPosition.x, currentDrawPosition.x)
val width = right - left
val height = bottom - top
val centerX = (left + right) / 2f
val centerY = (top + bottom) / 2f
val radius = min(width, height) / 2f
val newPath = Path().apply {
if (isRegular) {
val angleStep = 360f / vertices
val startAngle = rotationDegrees - 270.0
moveTo(
centerX + radius * cos(Math.toRadians(startAngle)).toFloat(),
centerY + radius * sin(Math.toRadians(startAngle)).toFloat()
)
for (i in 1 until vertices) {
val angle = startAngle + i * angleStep
lineTo(
centerX + radius * cos(Math.toRadians(angle)).toFloat(),
centerY + radius * sin(Math.toRadians(angle)).toFloat()
)
}
} else {
for (i in 0 until vertices) {
val angle = i * (360f / vertices) + rotationDegrees - 270.0
val x =
centerX + width / 2f * cos(Math.toRadians(angle)).toFloat()
val y =
centerY + height / 2f * sin(Math.toRadians(angle)).toFloat()
if (i == 0) {
moveTo(x, y)
} else {
lineTo(x, y)
}
}
}
close()
}
onPathChange(newPath)
}
}
fun drawStar(
vertices: Int,
innerRadiusRatio: Float,
rotationDegrees: Int,
isRegular: Boolean,
) {
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
val top = max(drawDownPosition.y, currentDrawPosition.y)
val left = min(drawDownPosition.x, currentDrawPosition.x)
val bottom = min(drawDownPosition.y, currentDrawPosition.y)
val right = max(drawDownPosition.x, currentDrawPosition.x)
val centerX = (left + right) / 2f
val centerY = (top + bottom) / 2f
val width = right - left
val height = bottom - top
val newPath = Path().apply {
if (isRegular) {
val outerRadius = min(width, height) / 2f
val innerRadius = outerRadius * innerRadiusRatio
val angleStep = 360f / (2 * vertices)
val startAngle = rotationDegrees - 270.0
for (i in 0 until (2 * vertices)) {
val radius = if (i % 2 == 0) outerRadius else innerRadius
val angle = startAngle + i * angleStep
val x = centerX + radius * cos(Math.toRadians(angle)).toFloat()
val y = centerY + radius * sin(Math.toRadians(angle)).toFloat()
if (i == 0) {
moveTo(x, y)
} else {
lineTo(x, y)
}
}
} else {
for (i in 0 until (2 * vertices)) {
val angle = i * (360f / (2 * vertices)) + rotationDegrees - 270.0
val radiusX =
(if (i % 2 == 0) width else width * innerRadiusRatio) / 2f
val radiusY =
(if (i % 2 == 0) height else height * innerRadiusRatio) / 2f
val x = centerX + radiusX * cos(Math.toRadians(angle)).toFloat()
val y = centerY + radiusY * sin(Math.toRadians(angle)).toFloat()
if (i == 0) {
moveTo(x, y)
} else {
lineTo(x, y)
}
}
}
close()
}
onPathChange(newPath)
}
}
fun drawTriangle() {
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
val newPath = Path().apply {
moveTo(drawDownPosition.x, drawDownPosition.y)
lineTo(currentDrawPosition.x, drawDownPosition.y)
lineTo(
(drawDownPosition.x + currentDrawPosition.x) / 2,
currentDrawPosition.y
)
lineTo(drawDownPosition.x, drawDownPosition.y)
close()
}
onPathChange(newPath)
}
}
fun drawRect(
rotationDegrees: Int,
cornerRadius: Float
) {
if (!drawDownPosition.isSpecified || !currentDrawPosition.isSpecified) return
val left = min(drawDownPosition.x, currentDrawPosition.x)
val right = max(drawDownPosition.x, currentDrawPosition.x)
val top = min(drawDownPosition.y, currentDrawPosition.y)
val bottom = max(drawDownPosition.y, currentDrawPosition.y)
val width = right - left
val height = bottom - top
if (width <= 0f || height <= 0f) return
val radius = min(width, height) * cornerRadius.coerceIn(0f, 0.5f)
val centerX = (left + right) / 2f
val centerY = (top + bottom) / 2f
val path = Path().apply {
addRoundRect(
RoundRect(
rect = Rect(left, top, right, bottom),
radius, radius
)
)
}
val matrix = Matrix().apply {
setRotate(rotationDegrees.toFloat(), centerX, centerY)
}
onPathChange(
path.asAndroidPath().apply { transform(matrix) }.asComposePath()
)
}
fun drawOval() {
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
val newPath = Path().apply {
addOval(
Rect(
top = max(
drawDownPosition.y,
currentDrawPosition.y
),
left = min(
drawDownPosition.x,
currentDrawPosition.x
),
bottom = min(
drawDownPosition.y,
currentDrawPosition.y
),
right = max(
drawDownPosition.x,
currentDrawPosition.x
),
)
)
}
onPathChange(newPath)
}
}
fun drawLine() {
if (drawDownPosition.isSpecified && currentDrawPosition.isSpecified) {
val newPath = Path().apply {
moveTo(drawDownPosition.x, drawDownPosition.y)
lineTo(currentDrawPosition.x, currentDrawPosition.y)
}
drawArrowsScope.drawArrowsIfNeeded(newPath)
onPathChange(newPath)
}
}
fun drawPath(
currentDrawPath: Path? = null,
onDrawFreeArrow: DrawArrowsScope.() -> Unit = {},
onBaseDraw: () -> Unit = {},
onFloodFill: (tolerance: Float) -> Unit = {}
) {
if (!isEraserOn && drawMode !is DrawMode.Warp) {
when (drawPathMode) {
is DrawPathMode.PointingArrow,
is DrawPathMode.DoublePointingArrow -> onDrawFreeArrow(drawArrowsScope)
is DrawPathMode.DoubleLinePointingArrow,
DrawPathMode.Line,
is DrawPathMode.LinePointingArrow -> drawLine()
is DrawPathMode.Rect -> drawRect(
rotationDegrees = drawPathMode.rotationDegrees,
cornerRadius = drawPathMode.cornerRadius
)
is DrawPathMode.OutlinedRect -> drawRect(
rotationDegrees = drawPathMode.rotationDegrees,
cornerRadius = drawPathMode.cornerRadius
)
is DrawPathMode.Triangle,
is DrawPathMode.OutlinedTriangle -> drawTriangle()
is DrawPathMode.Polygon -> {
drawPolygon(
vertices = drawPathMode.vertices,
rotationDegrees = drawPathMode.rotationDegrees,
isRegular = drawPathMode.isRegular
)
}
is DrawPathMode.OutlinedPolygon -> {
drawPolygon(
vertices = drawPathMode.vertices,
rotationDegrees = drawPathMode.rotationDegrees,
isRegular = drawPathMode.isRegular
)
}
is DrawPathMode.Star -> {
drawStar(
vertices = drawPathMode.vertices,
innerRadiusRatio = drawPathMode.innerRadiusRatio,
rotationDegrees = drawPathMode.rotationDegrees,
isRegular = drawPathMode.isRegular
)
}
is DrawPathMode.OutlinedStar -> {
drawStar(
vertices = drawPathMode.vertices,
innerRadiusRatio = drawPathMode.innerRadiusRatio,
rotationDegrees = drawPathMode.rotationDegrees,
isRegular = drawPathMode.isRegular
)
}
is DrawPathMode.Oval,
is DrawPathMode.OutlinedOval -> drawOval()
is DrawPathMode.Free,
is DrawPathMode.Lasso -> onBaseDraw()
is DrawPathMode.FloodFill -> onFloodFill(drawPathMode.tolerance)
is DrawPathMode.Spray -> {
currentDrawPath?.let {
val path = currentDrawPath.copy().apply {
repeat(drawPathMode.density) {
val angle = Random.nextFloat() * PI_2
val radius = sqrt(Random.nextFloat()) * strokeWidthSized
val x = currentDrawPosition.x + radius * cos(angle)
val y = currentDrawPosition.y + radius * sin(angle)
val rect = Rect(
left = x,
top = y,
right = x + drawPathMode.pixelSize,
bottom = y + drawPathMode.pixelSize
)
if (drawPathMode.isSquareShaped) addRect(rect) else addOval(rect)
}
}
onPathChange(path)
}
}
}
} else onBaseDraw()
}
}
private const val PI_2 = (Math.PI * 2).toFloat()
interface DrawArrowsScope {
fun drawArrowsIfNeeded(
drawPath: Path,
)
}
@Composable
fun rememberPathHelper(
drawDownPosition: Offset,
currentDrawPosition: Offset,
onPathChange: (Path) -> Unit,
strokeWidth: Pt,
canvasSize: IntegerSize,
drawPathMode: DrawPathMode,
isEraserOn: Boolean,
drawMode: DrawMode
): State<PathHelper> = remember(
drawDownPosition,
currentDrawPosition,
onPathChange,
strokeWidth,
canvasSize,
drawPathMode,
isEraserOn,
drawMode
) {
derivedStateOf {
PathHelper(
drawDownPosition = drawDownPosition,
currentDrawPosition = currentDrawPosition,
onPathChange = onPathChange,
strokeWidth = strokeWidth,
canvasSize = canvasSize,
drawPathMode = drawPathMode,
isEraserOn = isEraserOn,
drawMode = drawMode
)
}
}
@@ -0,0 +1,130 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.components.utils
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.magnifier
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.t8rin.gesture.pointerMotionEvents
import com.t8rin.imagetoolbox.core.ui.widget.modifier.observePointersCountWithOffset
import com.t8rin.imagetoolbox.core.ui.widget.modifier.smartDelayAfterDownInMillis
import net.engawapg.lib.zoomable.ZoomState
import net.engawapg.lib.zoomable.ZoomableDefaults.defaultZoomOnDoubleTap
import net.engawapg.lib.zoomable.zoomable
fun Modifier.pointerDrawObserver(
magnifierEnabled: Boolean,
currentDrawPosition: Offset,
zoomState: ZoomState,
globalTouchPointersCount: MutableIntState,
panEnabled: Boolean
) = this.composed {
var globalTouchPosition by remember { mutableStateOf(Offset.Unspecified) }
Modifier
.fillMaxSize()
.observePointersCountWithOffset { size, offset ->
globalTouchPointersCount.intValue = size
globalTouchPosition = offset
}
.then(
if (magnifierEnabled) {
Modifier.magnifier(
sourceCenter = {
if (currentDrawPosition.isSpecified) {
globalTouchPosition
} else Offset.Unspecified
},
magnifierCenter = {
globalTouchPosition - Offset(0f, 100.dp.toPx())
},
size = DpSize(height = 100.dp, width = 100.dp),
cornerRadius = 50.dp,
elevation = 2.dp
)
} else Modifier
)
.clipToBounds()
.zoomable(
zoomState = zoomState,
zoomEnabled = (globalTouchPointersCount.intValue >= 2 || panEnabled),
enableOneFingerZoom = panEnabled,
onDoubleTap = { pos ->
if (panEnabled) zoomState.defaultZoomOnDoubleTap(pos)
}
)
}
fun Modifier.pointerDrawHandler(
globalTouchPointersCount: MutableIntState,
onReceiveMotionEvent: (MotionEvent) -> Unit,
onInvalidate: () -> Unit,
onUpdateCurrentDrawPosition: (Offset) -> Unit,
onUpdateDrawDownPosition: (Offset) -> Unit,
enabled: Boolean
) = if (enabled) {
this.composed {
var drawStartedWithOnePointer by remember {
mutableStateOf(false)
}
Modifier.pointerMotionEvents(
onDown = { pointerInputChange ->
drawStartedWithOnePointer = globalTouchPointersCount.intValue <= 1
if (drawStartedWithOnePointer) {
onReceiveMotionEvent(MotionEvent.Down)
onUpdateCurrentDrawPosition(pointerInputChange.position)
onUpdateDrawDownPosition(pointerInputChange.position)
pointerInputChange.consume()
onInvalidate()
}
},
onMove = { pointerInputChange ->
if (drawStartedWithOnePointer) {
onReceiveMotionEvent(MotionEvent.Move)
onUpdateCurrentDrawPosition(pointerInputChange.position)
pointerInputChange.consume()
onInvalidate()
}
},
onUp = { pointerInputChange ->
if (drawStartedWithOnePointer) {
onReceiveMotionEvent(MotionEvent.Up)
pointerInputChange.consume()
onInvalidate()
}
drawStartedWithOnePointer = false
},
delayAfterDownInMillis = smartDelayAfterDownInMillis(globalTouchPointersCount.intValue)
)
}
} else {
this
}
@@ -0,0 +1,489 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.draw.presentation.screenLogic
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.core.net.toUri
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.childContext
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ImageCompressor
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.ImageScaler
import com.t8rin.imagetoolbox.core.domain.image.ImageShareProvider
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
import com.t8rin.imagetoolbox.core.domain.saving.FileController
import com.t8rin.imagetoolbox.core.domain.saving.model.ImageSaveTarget
import com.t8rin.imagetoolbox.core.domain.utils.smartJob
import com.t8rin.imagetoolbox.core.domain.utils.update
import com.t8rin.imagetoolbox.core.filters.domain.FilterProvider
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.filters.presentation.widget.FilterTemplateCreationSheetComponent
import com.t8rin.imagetoolbox.core.filters.presentation.widget.addFilters.AddFiltersSheetComponent
import com.t8rin.imagetoolbox.core.settings.domain.SettingsProvider
import com.t8rin.imagetoolbox.core.ui.utils.BaseComponent
import com.t8rin.imagetoolbox.core.ui.utils.helper.AppToastHost
import com.t8rin.imagetoolbox.core.ui.utils.navigation.Screen
import com.t8rin.imagetoolbox.core.ui.utils.state.savable
import com.t8rin.imagetoolbox.core.ui.utils.state.update
import com.t8rin.imagetoolbox.core.ui.widget.modifier.HelperGridParams
import com.t8rin.imagetoolbox.feature.draw.domain.DrawBehavior
import com.t8rin.imagetoolbox.feature.draw.domain.DrawLineStyle
import com.t8rin.imagetoolbox.feature.draw.domain.DrawMode
import com.t8rin.imagetoolbox.feature.draw.domain.DrawOnBackgroundParams
import com.t8rin.imagetoolbox.feature.draw.domain.DrawPathMode
import com.t8rin.imagetoolbox.feature.draw.domain.ImageDrawApplier
import com.t8rin.imagetoolbox.feature.draw.presentation.components.UiPathPaint
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
class DrawComponent @AssistedInject internal constructor(
@Assisted componentContext: ComponentContext,
@Assisted val initialUri: Uri?,
@Assisted val onGoBack: () -> Unit,
@Assisted val onNavigate: (Screen) -> Unit,
private val fileController: FileController,
private val imageTransformer: ImageTransformer<Bitmap>,
private val imageCompressor: ImageCompressor<Bitmap>,
private val imageDrawApplier: ImageDrawApplier<Bitmap, Path, Color>,
private val imageGetter: ImageGetter<Bitmap>,
private val imageScaler: ImageScaler<Bitmap>,
private val shareProvider: ImageShareProvider<Bitmap>,
private val filterProvider: FilterProvider<Bitmap>,
private val settingsProvider: SettingsProvider,
dispatchersHolder: DispatchersHolder,
addFiltersSheetComponentFactory: AddFiltersSheetComponent.Factory,
filterTemplateCreationSheetComponentFactory: FilterTemplateCreationSheetComponent.Factory
) : BaseComponent(dispatchersHolder, componentContext) {
init {
debounce {
initialUri?.let(::setUri)
}
}
val addFiltersSheetComponent: AddFiltersSheetComponent = addFiltersSheetComponentFactory(
componentContext = componentContext.childContext(
key = "addFilters"
)
)
val filterTemplateCreationSheetComponent: FilterTemplateCreationSheetComponent =
filterTemplateCreationSheetComponentFactory(
componentContext = componentContext.childContext(
key = "filterTemplateCreationSheetComponentDraw"
)
)
private val _drawOnBackgroundParams = fileController.savable(
scope = componentScope,
initial = DrawOnBackgroundParams.Default
)
val drawOnBackgroundParams: DrawOnBackgroundParams by _drawOnBackgroundParams
private val _imageBitmap: MutableState<ImageBitmap?> = mutableStateOf(null)
val imageBitmap: ImageBitmap? by _imageBitmap
private val _backgroundColor: MutableState<Color> = mutableStateOf(Color.Transparent)
val backgroundColor by _backgroundColor
private val _colorPickerBitmap: MutableState<Bitmap?> = mutableStateOf(null)
val colorPickerBitmap by _colorPickerBitmap
private val _drawBehavior: MutableState<DrawBehavior> = mutableStateOf(DrawBehavior.None)
val drawBehavior: DrawBehavior by _drawBehavior
private val _drawMode: MutableState<DrawMode> = mutableStateOf(DrawMode.Pen)
val drawMode: DrawMode by _drawMode
private val _drawPathMode: MutableState<DrawPathMode> = mutableStateOf(DrawPathMode.Free)
val drawPathMode: DrawPathMode by _drawPathMode
private val _drawLineStyle: MutableState<DrawLineStyle> = mutableStateOf(DrawLineStyle.None)
val drawLineStyle: DrawLineStyle by _drawLineStyle
private val _uri = mutableStateOf(Uri.EMPTY)
val uri: Uri by _uri
private val _paths = mutableStateOf(listOf<UiPathPaint>())
val paths: List<UiPathPaint> by _paths
private val _lastPaths = mutableStateOf(listOf<UiPathPaint>())
val lastPaths: List<UiPathPaint> by _lastPaths
private val _undonePaths = mutableStateOf(listOf<UiPathPaint>())
val undonePaths: List<UiPathPaint> by _undonePaths
private val _spotHealCache = mutableStateMapOf<Int, Bitmap>()
val spotHealCache: Map<Int, Bitmap> = _spotHealCache
val havePaths: Boolean
get() = paths.isNotEmpty() || lastPaths.isNotEmpty() || undonePaths.isNotEmpty()
private val _imageFormat = mutableStateOf(ImageFormat.Default)
val imageFormat by _imageFormat
private val _isSaving: MutableState<Boolean> = mutableStateOf(false)
val isSaving: Boolean by _isSaving
private val _saveExif: MutableState<Boolean> = mutableStateOf(false)
val saveExif: Boolean by _saveExif
private val _helperGridParams = fileController.savable(
scope = componentScope,
initial = HelperGridParams()
)
val helperGridParams: HelperGridParams by _helperGridParams
init {
componentScope.launch {
val settingsState = settingsProvider.getSettingsState()
_drawPathMode.update { DrawPathMode.fromOrdinal(settingsState.defaultDrawPathMode) }
}
}
private var savingJob: Job? by smartJob {
_isSaving.update { false }
}
fun saveBitmap(
oneTimeSaveLocationUri: String?
) {
savingJob = trackProgress {
_isSaving.value = true
getDrawingBitmap()?.let { localBitmap ->
parseSaveResult(
fileController.save(
saveTarget = ImageSaveTarget(
imageInfo = ImageInfo(
originalUri = _uri.value.toString(),
imageFormat = imageFormat,
width = localBitmap.width,
height = localBitmap.height
),
originalUri = _uri.value.toString(),
sequenceNumber = null,
data = imageCompressor.compressAndTransform(
image = localBitmap,
imageInfo = ImageInfo(
originalUri = _uri.value.toString(),
imageFormat = imageFormat,
width = localBitmap.width,
height = localBitmap.height
)
)
),
keepOriginalMetadata = _saveExif.value,
oneTimeSaveLocationUri = oneTimeSaveLocationUri
).onSuccess(::registerSave)
)
}
_isSaving.value = false
}
}
private fun calculateScreenOrientationBasedOnBitmap(bitmap: Bitmap): Int {
val imageRatio = bitmap.width / bitmap.height.toFloat()
return if (imageRatio <= 1.05f) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
}
fun setImageFormat(imageFormat: ImageFormat) {
_imageFormat.value = imageFormat
registerChanges()
}
fun setSaveExif(bool: Boolean) {
_saveExif.value = bool
registerChanges()
}
private fun updateBitmap(bitmap: Bitmap?) {
componentScope.launch {
_isImageLoading.value = true
val scaledBitmap = imageScaler.scaleUntilCanShow(bitmap)
val scaledImageBitmap = scaledBitmap?.let {
withContext(defaultDispatcher) {
it.copy(Bitmap.Config.ARGB_8888, true).asImageBitmap()
}
}
_imageBitmap.value = scaledImageBitmap
_isImageLoading.value = false
}
}
fun setUri(
uri: Uri
) {
componentScope.launch {
_paths.value = listOf()
_lastPaths.value = listOf()
_undonePaths.value = listOf()
_spotHealCache.clear()
_imageBitmap.value = null
_isImageLoading.value = true
_uri.value = uri
imageGetter.getImageData(
uri = uri.toString(),
size = 2500,
onFailure = AppToastHost::showFailureToast
)?.let { data ->
if (drawBehavior !is DrawBehavior.Background) {
_drawBehavior.update {
DrawBehavior.Image(calculateScreenOrientationBasedOnBitmap(data.image))
}
}
updateBitmap(data.image)
_imageFormat.update { data.imageInfo.imageFormat }
} ?: run {
_isImageLoading.value = false
}
}
}
private suspend fun getDrawingBitmap(): Bitmap? = withContext(defaultDispatcher) {
imageDrawApplier.applyDrawToImage(
drawBehavior = drawBehavior.let {
if (it is DrawBehavior.Background) it.copy(color = backgroundColor.toArgb())
else it
},
pathPaints = paths,
imageUri = _uri.value.toString()
)
}
fun openColorPicker() {
componentScope.launch {
_colorPickerBitmap.value = getDrawingBitmap()
}
}
fun resetDrawBehavior() {
_paths.value = listOf()
_lastPaths.value = listOf()
_undonePaths.value = listOf()
_spotHealCache.clear()
_imageBitmap.value = null
_drawBehavior.update {
DrawBehavior.None
}
_drawPathMode.update { DrawPathMode.Free }
_uri.value = Uri.EMPTY
_backgroundColor.value = Color.Transparent
registerChangesCleared()
}
fun startDrawOnBackground(
reqWidth: Int,
reqHeight: Int,
color: Color,
) {
val width = reqWidth.takeIf { it > 0 } ?: 1
val height = reqHeight.takeIf { it > 0 } ?: 1
val imageRatio = width / height.toFloat()
_drawBehavior.update {
DrawBehavior.Background(
orientation = if (imageRatio <= 1f) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
},
width = width,
height = height,
color = color.toArgb()
)
}
_backgroundColor.value = color
_spotHealCache.clear()
componentScope.launch {
val newValue = DrawOnBackgroundParams(
width = width,
height = height,
color = color.toArgb()
)
_drawOnBackgroundParams.update { newValue }
}
}
fun shareBitmap() {
savingJob = trackProgress {
_isSaving.value = true
getDrawingBitmap()?.let {
shareProvider.shareImage(
image = it,
imageInfo = ImageInfo(
originalUri = _uri.value.toString(),
imageFormat = imageFormat,
width = it.width,
height = it.height
),
onComplete = AppToastHost::showConfetti
)
}
_isSaving.value = false
}
}
fun updateBackgroundColor(color: Color) {
_backgroundColor.value = color
_spotHealCache.clear()
registerChanges()
}
fun clearDrawing() {
if (paths.isNotEmpty()) {
_lastPaths.value = paths
_paths.value = listOf()
_undonePaths.value = listOf()
_spotHealCache.clear()
registerChanges()
}
}
fun undo() {
if (paths.isEmpty() && lastPaths.isNotEmpty()) {
_paths.value = lastPaths
_lastPaths.value = listOf()
return
}
if (paths.isEmpty()) return
val lastPath = paths.last()
_paths.update { it - lastPath }
_undonePaths.update { it + lastPath }
registerChanges()
}
fun redo() {
if (undonePaths.isEmpty()) return
val lastPath = undonePaths.last()
_paths.update { it + lastPath }
_undonePaths.update { it - lastPath }
registerChanges()
}
fun addPath(pathPaint: UiPathPaint) {
_paths.update { it + pathPaint }
_undonePaths.value = listOf()
registerChanges()
}
fun removePath(pathPaint: UiPathPaint) {
_paths.update { it - pathPaint }
_spotHealCache.remove(pathPaint.hashCode())
registerChanges()
}
fun cacheSpotHealPathResult(
key: Int,
bitmap: Bitmap
) {
_spotHealCache[key] = bitmap
}
fun cancelSaving() {
savingJob?.cancel()
savingJob = null
_isSaving.value = false
}
suspend fun filter(
bitmap: Bitmap,
filters: List<Filter<*>>,
): Bitmap? = imageTransformer.transform(
image = bitmap,
transformations = filters.map { filterProvider.filterToTransformation(it) }
)
fun cacheCurrentImage(onComplete: (Uri) -> Unit) {
savingJob = trackProgress {
_isSaving.value = true
getDrawingBitmap()?.let { image ->
shareProvider.cacheImage(
image = image,
imageInfo = ImageInfo(
originalUri = _uri.value.toString(),
imageFormat = imageFormat,
width = image.width,
height = image.height
)
)?.let { uri ->
onComplete(uri.toUri())
}
}
_isSaving.value = false
}
}
fun updateDrawMode(drawMode: DrawMode) {
_drawMode.update { drawMode }
if (drawMode is DrawMode.Warp) {
_drawPathMode.update { DrawPathMode.Free }
_drawLineStyle.update { DrawLineStyle.None }
}
}
fun updateDrawPathMode(drawPathMode: DrawPathMode) {
_drawPathMode.update { drawPathMode }
}
fun getFormatForFilenameSelection(): ImageFormat = imageFormat
fun updateDrawLineStyle(style: DrawLineStyle) {
_drawLineStyle.update { style }
}
fun updateHelperGridParams(params: HelperGridParams) {
_helperGridParams.update { params }
}
@AssistedFactory
fun interface Factory {
operator fun invoke(
componentContext: ComponentContext,
initialUri: Uri?,
onGoBack: () -> Unit,
onNavigate: (Screen) -> Unit,
): DrawComponent
}
}