diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index 75955b945c..9af2a3d75d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -36,6 +36,7 @@ import org.signal.mediasend.SendRequest import org.signal.mediasend.SendResult import org.signal.mediasend.SentMediaQuality import org.signal.mediasend.StorySendRequirements +import org.signal.mediasend.edit.image.BrushWidths import org.signal.mediasend.preupload.PreUploadResult import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey @@ -63,6 +64,7 @@ import java.io.InputStream import java.util.Optional import java.util.concurrent.TimeUnit import kotlin.coroutines.resume +import kotlin.math.roundToInt import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -264,6 +266,21 @@ object MediaSendV3Repository : MediaSendRepository { override var storyMaxVideoDuration: Duration = Stories.MAX_VIDEO_DURATION_MILLIS.milliseconds + /** + * Stored as whole percentages so that the v2 and v3 editors stay in sync. + */ + override var brushWidths: BrushWidths + get() = BrushWidths( + marker = SignalStore.imageEditor.getMarkerPercentage() / 100f, + highlighter = SignalStore.imageEditor.getHighlighterPercentage() / 100f, + blur = SignalStore.imageEditor.getBlurPercentage() / 100f + ) + set(value) { + SignalStore.imageEditor.setMarkerPercentage((value.marker * 100).roundToInt()) + SignalStore.imageEditor.setHighlighterPercentage((value.highlighter * 100).roundToInt()) + SignalStore.imageEditor.setBlurPercentage((value.blur * 100).roundToInt()) + } + private fun PreUploadResult.toLegacyPreUploadResult(): MessageSender.PreUploadResult { return MessageSender.PreUploadResult(media, AttachmentId(attachmentId), jobIds) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index e546a6496e..29eaf70d9e 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -11,6 +11,7 @@ import android.os.Parcelable import kotlinx.coroutines.flow.Flow import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder +import org.signal.mediasend.edit.image.BrushWidths import org.signal.mediasend.preupload.PreUploadResult import java.io.InputStream import kotlin.time.Duration @@ -113,6 +114,11 @@ interface MediaSendRepository { fun getMediaConstraints(): MediaConstraints var storyMaxVideoDuration: Duration + + /** + * The image editor's per-tool brush widths, shared with the v2 editor. + */ + var brushWidths: BrushWidths } /** diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt index 4ab3175017..606378cffb 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt @@ -15,6 +15,7 @@ import org.signal.camera.CameraDependencies import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder import org.signal.core.models.parcelers.NullableCharSequenceParceler +import org.signal.mediasend.edit.image.BrushWidths import org.signal.mediasend.edit.video.VideoTrimData import kotlin.time.Duration @@ -132,7 +133,12 @@ data class MediaSendState( val storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled(), - val storyMaxVideoDuration: Duration = MediaSendDependencies.mediaSendRepository.storyMaxVideoDuration + val storyMaxVideoDuration: Duration = MediaSendDependencies.mediaSendRepository.storyMaxVideoDuration, + + /** + * The image editor's per-tool brush widths. Seeded from storage and written back as the user adjusts them. + */ + val brushWidths: BrushWidths = MediaSendDependencies.mediaSendRepository.brushWidths ) : Parcelable { fun getOrCreateVideoTrimData(uri: Uri): VideoTrimData { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 81ac4f4b4f..346f23d5a7 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -53,6 +53,7 @@ import org.signal.imageeditor.core.renderers.UriGlideRenderer import org.signal.mediasend.capture.CameraXScreenEvent import org.signal.mediasend.capture.MediaCaptureScreenEvent import org.signal.mediasend.edit.MediaEditScreenEvent +import org.signal.mediasend.edit.image.BrushTool import org.signal.mediasend.edit.video.VideoTrimData import org.signal.mediasend.preupload.PreUploadController import org.signal.mediasend.preupload.PreUploadResult @@ -302,9 +303,20 @@ class MediaSendViewModel( is MediaEditScreenEvent.RemoveMedia -> { removeMedia(mediaEditScreenEvent.media) } + + is MediaEditScreenEvent.BrushWidthChanged -> { + setBrushWidth(mediaEditScreenEvent.tool, mediaEditScreenEvent.fraction) + } } } + private fun setBrushWidth(tool: BrushTool, fraction: Float) { + val brushWidths = state.value.brushWidths.with(tool, fraction) + + updateState { copy(brushWidths = brushWidths) } + repository.brushWidths = brushWidths + } + private fun handleImageCaptured(imageCaptured: CameraXScreenEvent.ImageCaptured) { viewModelScope.launch { val media: Media? = withContext(Dispatchers.IO) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt index 1727cedde1..89533a15d0 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt @@ -18,6 +18,8 @@ import org.signal.imageeditor.core.SelectableRenderer import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel import org.signal.imageeditor.core.renderers.MultiLineTextRenderer +import org.signal.mediasend.edit.image.BrushTool +import org.signal.mediasend.edit.image.BrushWidthsState import org.signal.mediasend.edit.image.HSVColorBarState import org.signal.mediasend.edit.image.ImageEditorState @@ -30,7 +32,8 @@ import org.signal.mediasend.edit.image.ImageEditorState */ @Stable class ImageController @RememberInComposition constructor( - val editorModel: EditorModel + val editorModel: EditorModel, + private val brushWidths: BrushWidthsState = BrushWidthsState() ) { val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE } @@ -60,6 +63,7 @@ class ImageController @RememberInComposition constructor( private set val textColorBarState = HSVColorBarState() + val drawColorBarState = HSVColorBarState() var showDiscardDialog: Boolean by mutableStateOf(false) private set @@ -75,12 +79,26 @@ class ImageController @RememberInComposition constructor( } } - val shouldDisplayColorBar: Boolean by derivedStateOf { + val shouldDisplayTextColorBar: Boolean by derivedStateOf { textEditingElement != null || mode == Mode.MOVE_TEXT } val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT } val isUserBlurring: Boolean by derivedStateOf { mode == Mode.BLUR } + + val brushTool: BrushTool? by derivedStateOf { + when (mode) { + Mode.DRAW -> BrushTool.MARKER + Mode.HIGHLIGHT -> BrushTool.HIGHLIGHTER + Mode.BLUR -> BrushTool.BLUR + else -> null + } + } + + val brushWidthFraction: Float by derivedStateOf { brushTool?.let { brushWidths[it] } ?: 0f } + + val brushThickness: Float by derivedStateOf { brushTool?.thicknessAt(brushWidthFraction) ?: 0f } + val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT } val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER } @@ -176,14 +194,18 @@ class ImageController @RememberInComposition constructor( imageEditorState.drawColor = color } - fun setDrawThickness(thickness: Float) { - imageEditorState.drawThickness = thickness + fun setBrushWidthFraction(fraction: Float) { + val tool = brushTool ?: return + brushWidths.set(tool, fraction) + imageEditorState.drawThickness = tool.thicknessAt(fraction) } private fun syncDrawingState() { imageEditorState.isDrawing = true imageEditorState.isBlur = mode == Mode.BLUR imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND + imageEditorState.drawThickness = brushThickness + imageEditorState.drawColor = drawColorBarState.color } fun enterCropMode() { @@ -332,11 +354,13 @@ class ImageController @RememberInComposition constructor( } @Stable - class Container @RememberInComposition constructor() { + class Container @RememberInComposition constructor( + private val brushWidths: BrushWidthsState = BrushWidthsState() + ) { private val controllers = SnapshotStateMap() fun getOrCreate(uri: Uri, editorModel: EditorModel): ImageController { - return controllers.getOrPut(uri) { ImageController(editorModel) } + return controllers.getOrPut(uri) { ImageController(editorModel, brushWidths) } } } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index 2e0318df79..3db900332c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,6 +32,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp @@ -51,6 +53,11 @@ import org.signal.mediasend.EditorState import org.signal.mediasend.MediaSendDependencies import org.signal.mediasend.MediaSendState import org.signal.mediasend.edit.document.DocumentPage +import org.signal.mediasend.edit.image.BrushWidthBar +import org.signal.mediasend.edit.image.BrushWidthPreview +import org.signal.mediasend.edit.image.BrushWidthsState +import org.signal.mediasend.edit.image.ColorBarOrientation +import org.signal.mediasend.edit.image.HSVColorBar import org.signal.mediasend.edit.image.ImageEditor import org.signal.mediasend.edit.image.ImageEditorToolbar import org.signal.mediasend.edit.image.RotationDial @@ -89,13 +96,9 @@ fun MediaEditScreen( onEvent(MediaEditScreenEvent.NavigateBack) } - Box( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() - ) { + Box(modifier = Modifier.fillMaxSize()) { val isSmallWindowBreakpoint = rememberWindowBreakpoint() is WindowBreakpoint.Small - val imageControllers = remember { ImageController.Container() } + val imageControllers = remember { ImageController.Container(BrushWidthsState(state.brushWidths)) } val videoEditorViewModel = rememberVideoEditorViewModel() @@ -108,6 +111,7 @@ fun MediaEditScreen( } var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) } + var isAdjustingBrushWidth by remember(focusedUri) { mutableStateOf(false) } val isInteracting = imageController?.isUserInEdit == true || isVideoInteracting HorizontalPager( @@ -180,6 +184,36 @@ fun MediaEditScreen( } } + if (imageController != null && (imageController.isUserDrawing || imageController.isUserBlurring)) { + // A multi-touch commit/discard can tear down the bar mid-drag, so the terminal gesture callback is not guaranteed. + DisposableEffect(Unit) { + onDispose { isAdjustingBrushWidth = false } + } + + BrushWidthPreview( + visible = isAdjustingBrushWidth, + thickness = imageController.brushThickness, + viewMatrix = imageController.imageEditorState.viewMatrix, + color = Color(imageController.imageEditorState.drawColor), + isBlur = imageController.isUserBlurring, + modifier = Modifier.fillMaxSize() + ) + + BrushWidthBar( + fraction = imageController.brushWidthFraction, + onFractionChanged = { fraction, gestureComplete -> + isAdjustingBrushWidth = !gestureComplete + val tool = imageController.brushTool + imageController.setBrushWidthFraction(fraction) + + if (gestureComplete && tool != null) { + onEvent(MediaEditScreenEvent.BrushWidthChanged(tool, fraction)) + } + }, + modifier = Modifier.align(Alignment.CenterStart) + ) + } + val isTextEditing = imageController?.textEditingElement != null Column( @@ -187,6 +221,8 @@ fun MediaEditScreen( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .align(Alignment.BottomCenter) + .padding(bottom = 10.dp) + .navigationBarsPadding() .then(if (isTextEditing) Modifier.imePadding() else Modifier) ) { if (state.selectedMedia.size > 1 && !isInteracting) { @@ -222,6 +258,15 @@ fun MediaEditScreen( .padding(horizontal = 16.dp) ) } + + if (controller.isUserDrawing) { + HSVColorBar( + state = controller.drawColorBarState, + onColorChanged = controller::setDrawColor, + orientation = ColorBarOrientation.HORIZONTAL + ) + } + if (isSmallWindowBreakpoint) { ImageEditorToolbar(imageEditorController = controller, state = state, onEvent = onEvent) } @@ -271,17 +316,20 @@ fun MediaEditScreen( is EditorState.Document, EditorState.VideoGif, EditorState.Gif, null -> Unit } - AddAMessageRow( - enabled = !isInteracting && !state.isSending, - message = state.message, - onEvent = onEvent, - onNextClick = { onEvent(MediaEditScreenEvent.NextClick) }, - modifier = Modifier - .widthIn(max = 624.dp) - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .alpha(if (isInteracting) 0f else 1f) - ) + val showAddMessageRow = !(isInteracting && focusedEditorState !is EditorState.VideoTrim) + if (showAddMessageRow) { + AddAMessageRow( + enabled = !isInteracting && !state.isSending, + message = state.message, + onEvent = onEvent, + onNextClick = { onEvent(MediaEditScreenEvent.NextClick) }, + modifier = Modifier + .widthIn(max = 624.dp) + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .alpha(if (isInteracting) 0f else 1f) + ) + } } if (!isSmallWindowBreakpoint && imageController != null) { @@ -291,6 +339,7 @@ fun MediaEditScreen( onEvent = onEvent, modifier = Modifier .align(Alignment.CenterEnd) + .navigationBarsPadding() .padding(end = 24.dp) .then(if (isTextEditing) Modifier.imePadding() else Modifier) ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt index 2b824dd8a4..08e5a14f04 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -6,6 +6,7 @@ package org.signal.mediasend.edit import org.signal.core.models.media.Media +import org.signal.mediasend.edit.image.BrushTool import org.signal.mediasend.edit.video.VideoTrimData sealed interface MediaEditScreenEvent { @@ -17,6 +18,7 @@ sealed interface MediaEditScreenEvent { data object NavigateBack : MediaEditScreenEvent data object NavigateToGallery : MediaEditScreenEvent data object ToggleMediaQuality : MediaEditScreenEvent + data class BrushWidthChanged(val tool: BrushTool, val fraction: Float) : MediaEditScreenEvent data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvent data class VideoSeek(val positionUs: Long, val editingComplete: Boolean) : MediaEditScreenEvent } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthBar.kt new file mode 100644 index 0000000000..a188f1ded8 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthBar.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import org.signal.core.ui.compose.PhonePortraitDayPreview +import org.signal.core.ui.compose.PhonePortraitNightPreview +import org.signal.core.ui.compose.Previews + +/** + * Vertical brush thickness control, anchored to the start edge of the screen. + * + * Sits half off-screen at rest and slides inward for the duration of a drag. The top of the track is the + * thickest setting. + * + * The touchable area extends past the track towards the center of the screen so that the on-screen portion stays a + * full [BrushWidthMetrics.BarThickness] wide even while the track is half clipped. + */ +@Composable +fun BrushWidthBar( + fraction: Float, + onFractionChanged: (fraction: Float, gestureComplete: Boolean) -> Unit, + modifier: Modifier = Modifier +) { + var isDragging by remember { mutableStateOf(false) } + val trackPath = remember { Path() } + + val offsetX by animateDpAsState( + targetValue = with(BrushWidthMetrics) { if (isDragging) RestingOffset + SlideInDistance else RestingOffset }, + animationSpec = tween(durationMillis = BrushWidthMetrics.SlideDurationMillis, easing = BrushWidthMetrics.SlideEasing) + ) + + Canvas( + modifier = modifier + .offset(x = offsetX) + .size(width = BrushWidthMetrics.TouchThickness, height = BrushWidthMetrics.BarLength) + .pointerInput(Unit) { + awaitEachGesture { + val thumbRadius = BrushWidthMetrics.ThumbDiameter.toPx() / 2f + val down = awaitFirstDown() + down.consume() + isDragging = true + + var latest = fractionAt(down.position.y, size.height.toFloat(), thumbRadius) + onFractionChanged(latest, false) + + drag(down.id) { change -> + change.consume() + latest = fractionAt(change.position.y, size.height.toFloat(), thumbRadius) + onFractionChanged(latest, false) + } + + isDragging = false + onFractionChanged(latest, true) + } + } + ) { + drawTrack(trackPath) + drawThumb(fraction) + } +} + +private fun fractionAt(y: Float, height: Float, thumbRadius: Float): Float { + val travel = height - thumbRadius * 2f + if (travel <= 0f) { + return 0f + } + + return (1f - (y - thumbRadius) / travel).coerceIn(0f, 1f) +} + +private fun DrawScope.drawTrack(path: Path) = with(BrushWidthMetrics) { + val centerX = BarThickness.toPx() / 2f + val topHalfThickness = TrackTopThickness.toPx() / 2f + val bottomHalfThickness = TrackBottomThickness.toPx() / 2f + + path.rewind() + path.moveTo(centerX - topHalfThickness, 0f) + path.lineTo(centerX + topHalfThickness, 0f) + path.lineTo(centerX + bottomHalfThickness, size.height) + path.lineTo(centerX - bottomHalfThickness, size.height) + path.close() + + drawPath(path = path, color = Color.White, alpha = TrackAlpha) +} + +private fun DrawScope.drawThumb(fraction: Float) = with(BrushWidthMetrics) { + val radius = ThumbDiameter.toPx() / 2f + val travel = size.height - radius * 2f + + drawCircle( + color = Color.White, + radius = radius, + center = Offset(BarThickness.toPx() / 2f, radius + (1f - fraction) * travel) + ) +} + +@PhonePortraitDayPreview +@PhonePortraitNightPreview +@Composable +private fun BrushWidthBarPreview() { + Previews.Preview { + BrushWidthBar( + fraction = 0.5f, + onFractionChanged = { _, _ -> }, + modifier = Modifier.offset(x = BrushWidthMetrics.BarThickness) + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthMetrics.kt new file mode 100644 index 0000000000..625661db47 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthMetrics.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.ui.unit.dp + +/** + * Dimensions and animation values shared by [BrushWidthBar] and [BrushWidthPreview], carried over from the v2 editor. + */ +internal object BrushWidthMetrics { + val BarLength = 174.dp + val BarThickness = 48.dp + + /** How much of [BarThickness] hangs off the start edge of the screen at rest. */ + val RestingClip = BarThickness / 2 + + /** Cross-axis size of the layout box, extended inwards to keep [BarThickness] of it touchable at rest. */ + val TouchThickness = BarThickness + RestingClip + + val RestingOffset = -RestingClip + val SlideInDistance = 36.dp + val SlideEasing = CubicBezierEasing(0.17f, 0.17f, 0f, 1f) + const val SlideDurationMillis = 250 + + val TrackTopThickness = 16.dp + val TrackBottomThickness = 0.3.dp + const val TrackAlpha = 0.6f + + val ThumbDiameter = 32.dp + + val PreviewBackdropWidth = 1.dp + const val PreviewFadeDurationMillis = 150 +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthPreview.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthPreview.kt new file mode 100644 index 0000000000..53f79f50f3 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthPreview.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import android.graphics.Matrix +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import org.signal.imageeditor.core.Bounds + +/** + * Shows the size of the brush the user is about to draw with, centered on the editor. + * + * Blur has no color of its own, so it gets the backdrop ring only. + */ +@Composable +fun BrushWidthPreview( + visible: Boolean, + thickness: Float, + viewMatrix: Matrix, + color: Color, + isBlur: Boolean, + modifier: Modifier = Modifier +) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = tween(durationMillis = BrushWidthMetrics.PreviewFadeDurationMillis) + ) + + if (alpha <= 0f) { + return + } + + Canvas(modifier = modifier) { + val radius = viewMatrix.mapRadius(thickness * Bounds.FULL_BOUNDS.width() / 2f) + val center = Offset(size.width / 2f, size.height / 2f) + + drawCircle( + color = Color.White, + radius = radius + BrushWidthMetrics.PreviewBackdropWidth.toPx(), + center = center, + alpha = alpha + ) + + if (!isBlur) { + drawCircle( + color = color, + radius = radius, + center = center, + alpha = alpha + ) + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt new file mode 100644 index 0000000000..2567c0322a --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import android.os.Parcelable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.annotation.RememberInComposition +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.parcelize.Parcelize + +/** + * The image editor tools which support a user-adjustable stroke width. + * + * Thicknesses are expressed as a fraction of the editor's coordinate space width, matching what + * [ImageEditorState.drawThickness] expects. + */ +enum class BrushTool(val minThickness: Float, val maxThickness: Float) { + MARKER(0.01f, 0.05f), + HIGHLIGHTER(0.03f, 0.08f), + BLUR(0.052f, 0.092f); + + fun thicknessAt(fraction: Float): Float = minThickness + (maxThickness - minThickness) * fraction +} + +/** + * The stroke width each [BrushTool] is set to, as a fraction of that tool's own thickness range. + */ +@Parcelize +data class BrushWidths( + val marker: Float = 0f, + val highlighter: Float = 0f, + val blur: Float = 0f +) : Parcelable { + + operator fun get(tool: BrushTool): Float { + return when (tool) { + BrushTool.MARKER -> marker + BrushTool.HIGHLIGHTER -> highlighter + BrushTool.BLUR -> blur + } + } + + fun with(tool: BrushTool, fraction: Float): BrushWidths { + return when (tool) { + BrushTool.MARKER -> copy(marker = fraction) + BrushTool.HIGHLIGHTER -> copy(highlighter = fraction) + BrushTool.BLUR -> copy(blur = fraction) + } + } +} + +/** + * Mutable holder for [BrushWidths], scoped to the edit screen rather than to a single image so that every image in the + * selection draws with the same brush. + */ +@Stable +class BrushWidthsState @RememberInComposition constructor(initialWidths: BrushWidths = BrushWidths()) { + + var widths: BrushWidths by mutableStateOf(initialWidths) + private set + + operator fun get(tool: BrushTool): Float = widths[tool] + + fun set(tool: BrushTool, fraction: Float) { + widths = widths.with(tool, fraction) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt index baf4592574..0dd17dd2ef 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt @@ -41,13 +41,9 @@ import org.signal.core.ui.rememberWindowBreakpoint fun HSVColorBar( state: HSVColorBarState, onColorChanged: (Int) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + orientation: ColorBarOrientation = rememberDefaultColorBarOrientation() ) { - val orientation = if (rememberWindowBreakpoint() is WindowBreakpoint.Small) { - ColorBarOrientation.HORIZONTAL - } else { - ColorBarOrientation.VERTICAL - } val colors = remember { HSVColors.composeColors } val thumbColor = SignalTheme.colors.colorSurface5 @@ -85,7 +81,11 @@ fun HSVColorBar( } } -private enum class ColorBarOrientation(val barModifier: Modifier) { +/** + * Whichever axis the bar is laid out along. Defaults to [rememberDefaultColorBarOrientation], but call sites that + * always host the bar in a fixed slot should pass their own. + */ +enum class ColorBarOrientation(internal val barModifier: Modifier) { HORIZONTAL( Modifier .widthIn(max = MAX_BAR_LENGTH_DP.dp) @@ -99,7 +99,7 @@ private enum class ColorBarOrientation(val barModifier: Modifier) { .width(THUMB_DIAMETER_DP.dp) ); - fun fractionAt(offset: Offset, width: Float, height: Float): Float { + internal fun fractionAt(offset: Offset, width: Float, height: Float): Float { val distance = when (this) { HORIZONTAL -> offset.x VERTICAL -> offset.y @@ -113,7 +113,7 @@ private enum class ColorBarOrientation(val barModifier: Modifier) { return (distance / maxDistance).coerceIn(0f, 1f) } - fun thumbCenter(fraction: Float, size: Size): Offset { + internal fun thumbCenter(fraction: Float, size: Size): Offset { return when (this) { HORIZONTAL -> Offset(fraction * size.width, size.height / 2f) VERTICAL -> Offset(size.width / 2f, fraction * size.height) @@ -185,6 +185,18 @@ fun rememberHSVColorBarState(): HSVColorBarState { return remember { HSVColorBarState() } } +/** + * Lays the bar out along whichever axis has room in the current window. + */ +@Composable +fun rememberDefaultColorBarOrientation(): ColorBarOrientation { + return if (rememberWindowBreakpoint() is WindowBreakpoint.Small) { + ColorBarOrientation.HORIZONTAL + } else { + ColorBarOrientation.VERTICAL + } +} + private object HSVColors { private const val MAX_HUE = 360 private const val BLACK_DIVISIONS = 175 @@ -245,7 +257,7 @@ private object HSVColors { } private const val DEFAULT_FRACTION = 0.14f -private const val MAX_BAR_LENGTH_DP = 320 +private const val MAX_BAR_LENGTH_DP = 324 private const val TRACK_THICKNESS_DP = 20 private const val THUMB_DIAMETER_DP = 24 private const val THUMB_BORDER_DP = 6 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt index b71c991203..47eadc937b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt @@ -14,6 +14,7 @@ import android.graphics.RectF import android.graphics.Typeface import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -47,7 +48,7 @@ class ImageEditorState( var textEditingElement: EditorElement? = null var isDrawing: Boolean = false var isBlur: Boolean = false - var drawColor: Int = 0xff000000.toInt() + var drawColor: Int by mutableIntStateOf(0xff000000.toInt()) var drawThickness: Float = 0.02f var drawCap: Paint.Cap = Paint.Cap.ROUND var onGestureCompleted: (() -> Unit)? = null diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt index 47f9a223fe..2b2f8cda23 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt @@ -52,7 +52,7 @@ fun ImageEditorToolbar( modifier: Modifier = Modifier ) { when { - imageEditorController.shouldDisplayColorBar -> { + imageEditorController.shouldDisplayTextColorBar -> { HSVColorBar( state = imageEditorController.textColorBarState, onColorChanged = imageEditorController::setTextColor,